Skip to content
syncdo

The Syncdo API for leads, deals and webhooks

Bring leads in from your own systems, and get events out the moment they happen.

curl -X POST https://api.syncdo.co/v1/leads \
  -H "Authorization: Bearer sya_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+1 555 555 0123", "name": "Avery Stone",
        "company": "Northwind Studio" }'

HTTP/1.1 201
{ "ok": true, "created": true, "lead": { … } }

What it does

Create leads

Exactly like a form enquiry: a known number is updated, never duplicated.

{ "ok": true, "created": true, "result": "created",
  "lead": { "name": "Avery Stone", "stage": "New",
            "company": "Northwind Studio", "source": "inbound:Zapier" } }

Find and update

By phone, email, name or when added; your own fields by key.

GET /v1/leads?phone=%2B15555550123
GET /v1/leads?q=northwind

Notes and stages

Add notes and move a lead's stage.

Deals

Open, edit and close deals; read the pipeline.

Reminders

Open reminders due today, overdue or upcoming.

Webhooks out

Pick the events: new lead, stage changed, deal won, deal lost, reminder due. Each is posted as JSON, usually within a minute.

  • Signed with your secret, so you can check it came from Syncdo.
  • Retried 7 times, from at once to after 12 hours.
  • Every delivery listed for 30 days, with Resend.
  • Send test event, before anything real happens.
POST https://example.com/hooks/syncdo
Syncdo-Event: lead.stage_changed
Syncdo-Signature: t=1791364500,v1=5d2a…

{ "type": "lead.stage_changed",
  "created_at": "2026-10-07T09:15:00.000Z",
  "data": { "lead": { … },
            "change": { "from": "New", "to": "Contacted" } } }

Limits, said up front

  • 120 a minute

    Requests of each kind, shared by the workspace's keys.

  • 60 new leads a minute

    Per key.

  • Every refusal says why

    In a sentence, not a bare code.

Getting started

The owner makes an API key in Settings → API & webhooks — one key for each tool, named after it (“Zapier”, “Website”, “n8n”). The key is shown once. Every request goes to https://api.syncdo.co/v1 with JSON bodies.

curl https://api.syncdo.co/v1/me \
  -H "Authorization: Bearer sya_YOUR_KEY"

{ "ok": true,
  "workspace": { "id": "…", "name": "Northwind Studio" },
  "key": { "id": "…", "name": "Zapier", "prefix": "sya_3kPq8xZ" },
  "api_version": "2026-10-07" }

GET /v1/me is the call to make when a tool asks you to test the connection.

Authentication

Send the key in the Authorization header: Authorization: Bearer sya_…. A key belongs to the workspace, not to a person, and can read and change every lead in it — keep it on a server, never in a web page. The owner can pause a key (calls are refused until it is resumed) or revoke it (final). What a key created stays, and each lead's history says “API: <key name>” for every change it made.

An API key works only under /v1, and /v1 takes only API keys.

Errors and limits

Every answer has ok. A failure also has error (a code to branch on) and message (a sentence to show a person).

HTTP/1.1 400
{ "ok": false, "error": "unknown_stage",
  "message": "Send \"stage\" as one of this workspace's lead stages: New, Contacted, Interested, …",
  "stages": ["New", "Contacted", "Interested", "…"] }
StatuserrorMeaning
401api_key_requiredNo key, or not an API key (a dashboard session or an extension key).
401invalid_api_keyThe key is not known — check it was copied whole.
401api_key_revokedThe key was revoked.
401api_key_scopeAn API key was sent to something that is not the API.
403api_key_pausedThe owner paused the key.
403api_not_enabledThe API is not switched on for this workspace.
403plan_requiredThe workspace’s plan does not include the API.
400variousSomething in the request: the message says what.
404lead_not_foundNo such lead (or deal, or path) in this workspace.
409variousA conflict, e.g. deal_already_open, deal_not_open.
429rate_limitedToo many requests — wait for Retry-After seconds.
503unavailableSyncdo could not do it just now. Nothing was changed; try again.

Up to 120 requests a minute for each kind of request (for example, all POST /v1/leads) in a workspace, shared by all its keys. Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining. Creating leads is also limited to 60 a minute per key.

Leads

Wherever a request names a lead (:lead), send its phone number in any format (with its country code) or its id.

The lead object

{
  "id": "5b0d7c1e-2f4a-4c8e-9d61-0a7f3b2c9e14",
  "phone": "15555550123",
  "phone_e164": "+15555550123",
  "name": "Avery Stone",
  "stage": "Contacted",
  "email": "avery@example.com",
  "company": "Northwind Studio",
  "city": "Springfield",
  "state": "IL",
  "source": "inbound:Zapier",
  "batch": "Zapier",
  "owner": { "id": "a41c…", "name": "Jordan Lee" },
  "fields": { "budget": 5000 },
  "created_at": "2026-10-07T09:12:03.120Z",
  "updated_at": "2026-10-07T09:20:41.004Z"
}

fields holds the workspace's own lead fields (see GET /v1/fields); the built-in ones are at the top level.

Create a lead — POST /v1/leads

A lead sent here arrives exactly like an enquiry from a website form: a number that is already a lead updates that lead instead of creating a second one, and the key's settings decide who gets a new lead and what happens to fields the lead already has. Only phone is required.

curl -X POST https://api.syncdo.co/v1/leads \
  -H "Authorization: Bearer sya_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{ "phone": "+1 555 555 0123", "name": "Avery Stone", "email": "avery@example.com",
        "company": "Northwind Studio", "message": "Asked about the annual plan",
        "id": "order-1001", "fields": { "budget": 5000, "tier": "Gold" } }'

HTTP/1.1 201
{ "ok": true, "created": true, "result": "created", "lead": { … } }
FieldNotes
phoneRequired. With a leading + and its country code it is taken as given; a national number uses country, else the workspace's default country.
nameThe lead's name.
email, company, city, stateSaved on the lead.
messageSaved as a note on the lead (it is not a WhatsApp message).
countryTwo letters (US, GB, IN…) for a number sent without its country code.
idYour own reference. Sent again within 30 days, it is recognised: duplicate: true, nothing changes.
fieldsThe workspace's own lead fields, by key (see GET /v1/fields), checked as PATCH checks them: a number, a date as YYYY-MM-DD, true/false, one of a dropdown's options, or a list of words for tags. A blank value never erases one already there.

Answers: 201 created; 200 with "result": "updated" for a number that was already a lead; 200 with "duplicate": true for a repeat. Fields Syncdo does not take are listed back inignored_fields; a key under fields that the workspace does not have is listed as fields.key. Unlike PATCH, a value that does not fit its field does not refuse the lead: that field is left out and listed inrejected_fields, as { "field": "fields.budget", "message": "Budget: send a number." }, and the lead is saved with the rest — the same as from a webhook or a Google Sheet. Errors: phone_required, contact_limit_reached (the plan's contact limit).

Find leads — GET /v1/leads

GET /v1/leads?phone=%2B15555550123
GET /v1/leads?email=avery@example.com
GET /v1/leads?created_since=2026-10-01T00:00:00Z&limit=50
GET /v1/leads?updated_since=2026-10-07T09:00:00Z&cursor=eyJ…
GET /v1/leads?q=northwind
GET /v1/leads?q=%2B1%20555&stage=contacted
GET /v1/leads?assigned_to=a41c…            (or assigned_to=none)

{ "ok": true, "leads": [ { … }, { … } ], "next_cursor": "eyJ…" }

Newest first. limit is 1–100 (25 by default); pass next_cursor back as cursor for the next page.

FilterNotes
phoneOne number, in any format with its country code.
emailAn exact email address.
created_since, updated_sinceA date and time (ISO 8601): leads created or changed since then.
qA search over the name, the company and the phone number's digits, ignoring capitals. Up to 100 characters: letters, digits, spaces and . @ + - ' & / # — anything else (a comma, a bracket, an asterisk, a quote) is refused with 400 bad_query, naming it.
stageOne of the workspace's lead stages, any capitals. An unknown stage is refused (unknown_stage) with the list.
assigned_toA member's id (from a lead's owner.id), or none for unassigned leads. A member of another workspace is refused (unknown_member).

Filters combine (all must match), and work with cursor.

Get one lead — GET /v1/leads/:lead

GET /v1/leads/%2B15555550123
{ "ok": true, "lead": { … } }

GET /v1/leads/%2B15555550123?include=timeline
{ "ok": true, "lead": { … },
  "timeline": [
    { "type": "field", "summary": "Lead stage changed from “New” to “Contacted”", "by": "Jordan Lee", "at": "2026-10-07T09:20:41.004Z" },
    { "type": "message", "summary": "Received: “Is the annual plan still on offer?”", "by": "the customer", "at": "…" } ],
  "next_reminder": { "id": "…", "lead": { "id": "…", "name": "Avery Stone", "phone": "15555550123", "phone_e164": "+15555550123" },
    "due_at": "2026-10-08T15:00:00.000Z", "overdue": false, "title": "Call back", "note": null,
    "type": { "key": "follow_up", "label": "Follow-up" }, "status": "open", "assigned_to": { "id": "…", "name": "Jordan Lee" } } }

include=timeline adds the lead's last 10 events, newest first, each in the words the Syncdo dashboard uses (by is who did it, when that is known), and its next open reminder (null when there is none). Without it the answer is just the lead.

Update a lead — PATCH /v1/leads/:lead

PATCH /v1/leads/%2B15555550123
{ "name": "Avery S. Stone", "city": "Springfield",
  "fields": { "budget": 5000, "tier": "Gold" } }

Send name, email, company, city, state and the workspace's own fields underfields. A value you send replaces what is there; a blank name never erases a name. Each field is checked against its type — a number, a date as YYYY-MM-DD, true/false, one of a dropdown's options, or a list of words for tags. A field the workspace does not have is refused by name (unknown_field) rather than stored where nobody would see it. Stages have their own request.

Notes

POST /v1/leads/%2B15555550123/notes
{ "text": "Sent the brochure." }
→ 201 { "ok": true, "note": { "text": "Sent the brochure.", "author": "API: Zapier" } }

GET /v1/leads/%2B15555550123/notes
→ { "ok": true, "notes": [ { "id": "…", "text": "…", "author": "…", "created_at": "…" } ] }

A note is up to 4000 characters.

Stages and fields

GET /v1/stages
{ "ok": true, "lead_stages": ["New", "Contacted", "Interested", "Converted", "Lost"],
  "deal_stages": ["New", "Active", "Quoted", "Negotiation"] }

POST /v1/leads/%2B15555550123/stage
{ "stage": "interested" }
→ { "ok": true, "changed": true, "lead": { …, "stage": "Interested" } }

GET /v1/fields
{ "ok": true, "fields": [ { "key": "phone", "label": "Phone", "type": "phone", "builtin": true, "required_on_create": true },
                          { "key": "tier", "label": "Tier", "type": "dropdown", "options": ["Gold", "Silver"], "builtin": false } ] }

A stage must be one of the workspace's own (any capitalisation). Moving a stage through the API runs the workspace’s rules as any move does.

Deals

POST /v1/deals
{ "lead": "+15555550123", "title": "Annual plan", "value": 1200, "currency": "USD",
  "stage": "Active", "expected_close_date": "2026-11-01", "priority": "high" }
→ 201 { "ok": true, "deal": {
  "id": "…", "number": 42, "lead_id": "…", "title": "Annual plan", "stage": "Active", "status": "open",
  "value": 1200, "value_at_close": null, "currency": "USD", "lost_reason": null,
  "expected_close_date": "2026-11-01", "priority": "high", "description": null,
  "owner": { "id": "…", "name": "Jordan Lee" }, "created_at": "…", "closed_at": null } }

PATCH /v1/deals/:id          { "value": 1500, "stage": "Quoted" }
POST  /v1/deals/:id/close    { "status": "won", "value": 1450 }
POST  /v1/deals/:id/close    { "status": "lost", "lost_reason": "Went with another option" }
GET   /v1/deals?lead=%2B15555550123&status=all
GET   /v1/deals/:id

A lead has at most one open deal (deal_already_open). A new deal is assigned to the person the lead is assigned to. Withoutcurrency the workspace's currency is used. A won deal needs the value it closed at; a lost deal needs a reason; cancelled needs neither. A closed deal cannot be edited (deal_not_open).

Reminders

GET /v1/reminders                       (due today or overdue)
GET /v1/reminders?due=today
GET /v1/reminders?due=overdue&assigned_to=a41c…
GET /v1/reminders?due=upcoming&lead=%2B15555550123

{ "ok": true, "due": "today_and_overdue", "timezone": "America/New_York",
  "window": { "from": null, "to": "2026-10-08T03:59:59.999Z" },
  "reminders": [ { "id": "…", "lead": { "id": "…", "name": "Avery Stone", "phone": "15555550123", "phone_e164": "+15555550123" },
                   "due_at": "2026-10-07T14:00:00.000Z", "overdue": true, "title": "Send the quote", "note": null,
                   "type": { "key": "follow_up", "label": "Follow-up" }, "status": "open",
                   "assigned_to": { "id": "…", "name": "Jordan Lee" } } ],
  "next_cursor": null }

Open reminders, earliest first. due is today, overdue (due before now), upcoming (after today) or today_and_overdue (the default). “Today” is today in the workspace's time zone (Settings → General), which the answer names. Filter by lead (phone or id) and assigned_to (a member's id, or none); limit 1–100 and cursor as for leads. On the plans with reminders (not Free: plan_required).

Pipeline

GET /v1/pipeline
{ "ok": true, "timezone": "America/New_York",
  "month": { "from": "2026-10-01T04:00:00.000Z", "to": "2026-10-08T04:00:00.000Z" },
  "leads": { "total": 214,
             "by_stage": [ { "stage": "New", "count": 61 }, { "stage": "Contacted", "count": 88 }, { "stage": "Interested", "count": 40 } ],
             "other_stages": [ { "stage": "Archived", "count": 3 } ], "no_stage": 0 },
  "deals": { "open": { "count": 12, "totals": [ { "currency": "USD", "value": 18400 } ] },
             "won_this_month": { "count": 4, "totals": [ { "currency": "USD", "value": 5150 } ] },
             "lost_this_month": { "count": 2, "totals": [ { "currency": "EUR", "value": 900 } ] } } }

Leads in each of the workspace's stages, in its order (leads in a stage that is no longer one are listed apart); deals open now, and won and lost this month in the workspace's time zone, with totals per currency — the same numbers as Reports. deals is null, with deals_unavailable saying why, on a plan without deals.

Webhooks

The owner adds an endpoint in Settings → API & webhooks — a name, an https URL and the events to send — and is shown its signing secret once. Syncdo then posts each event to the URL as JSON, usually within a minute of it happening.

EventWhen
lead.createdA lead is added — from WhatsApp, the dashboard, a website form, a Google Sheet, the API or a call. Leads from a CSV import are not sent.
lead.stage_changedA lead's stage changes, from anywhere. data.change has from and to.
deal.wonA deal is closed as won. data.deal is the deal.
deal.lostA deal is closed as lost, with its reason.
reminder.dueAn open reminder's time arrives. data.reminder has its type, title, due time and owner.
testSent by "Send test event". Fictional data.
POST https://example.com/hooks/syncdo
Content-Type: application/json
User-Agent: Syncdo-Webhooks/1
Syncdo-Event: lead.stage_changed
Syncdo-Event-Id: evt_3f9c2a71d04b8e6a5c1f0b92
Syncdo-Delivery: 7c0e…
Syncdo-Signature: t=1791364500,v1=5d2a…

{ "id": "evt_3f9c2a71d04b8e6a5c1f0b92",
  "type": "lead.stage_changed",
  "created_at": "2026-10-07T09:15:00.000Z",
  "api_version": "2026-10-07",
  "workspace_id": "…",
  "test": false,
  "data": { "lead": { … }, "change": { "from": "New", "to": "Contacted" } } }

Answer with any 2xx within 10 seconds. Anything else — a timeout, an error, a redirect (they are not followed) — is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: 7 attempts in all. id is the same on every attempt, so a receiver can ignore one it has already handled. Events may arrive out of order.

An endpoint that fails 20 times in a row over at least a day is turned off, and the settings page says why; an answer of410 Gone turns it off at once (that is how an app says it has been disconnected). The owner turns it back on with one click; events from then on are sent. Pausing an endpoint cancels the deliveries waiting to be retried, and events that happen while it is paused are not sent; any delivery can be sent again from its log (kept 30 days).

Checking a signature

Syncdo-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<the raw request body> with the endpoint's secret. Check it against the body exactly as received, and refuse a t more than five minutes old.

// Node.js
const crypto = require('crypto');
function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const want = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex');
  return want.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
# Python
import hmac, hashlib, time
def verify(secret, header, raw_body):
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    want = hmac.new(secret.encode(), (parts["t"] + "." + raw_body).encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts["v1"])

Subscriptions for apps

An integration app can subscribe to events with its own key, instead of someone adding an endpoint by hand. A subscription is an endpoint like any other — the owner sees it in Settings → API & webhooks — and a key sees and removes only the subscriptions it made.

POST /v1/webhooks
{ "url": "https://hooks.example.com/catch/123", "events": ["lead.created"] }
→ 201 { "ok": true, "webhook": { "id": "…", "url": "…", "events": ["lead.created"], "active": true },
        "secret": "whsec_…" }

GET    /v1/webhooks        → this key's subscriptions
DELETE /v1/webhooks/:id    → { "ok": true, "deleted": true }

For a polling trigger instead, ask for GET /v1/leads?created_since=… and keep the newest created_at you have seen.

Claude and ChatGPT (MCP)

Syncdo is also an MCP server, so a person can ask Claude, ChatGPT or another app that speaks the Model Context Protocol about their leads: “Who should I follow up with today?”, “What's happening with Avery Stone?”, “How did this month go?”. It is read only — nothing it does sends a WhatsApp message or changes anything — and it needs no API key: each person connects their own Syncdo login, and the app sees exactly what that person sees in Syncdo (with “Reps see only leads assigned to them” on, a rep's app sees only their leads).

Connector URL:  https://api.syncdo.co/mcp

Add it as a custom connector in the app. The app opens a Syncdo page that asks the person to sign in (as usual) and to allow it; they can disconnect it at any time in Settings → My account → Connected apps, and the workspace owner sees and can disconnect every member's apps in Settings → API & webhooks. On the paid plans, like the API — and, unlike the API, it is being turned on one workspace at a time: where it is not on yet, the Syncdo page says so.

ToolWhat it does
search_leadsFinds leads by name, company or part of a phone number, and/or stage (GET /v1/leads).
get_leadOne lead: its details, its last 10 events and its next reminder (GET /v1/leads/:lead?include=timeline).
list_remindersOpen reminders due today, overdue, both, or upcoming, in the workspace's time zone (GET /v1/reminders).
pipeline_summaryLeads per stage; deals open, and won and lost this month (GET /v1/pipeline).

For the people who build MCP clients: Streamable HTTP at POST /mcp (JSON answers, no session; protocol versions 2025-11-25, 2025-06-18 and 2025-03-26), and OAuth 2.1 with Syncdo as its own authorization server — protected resource metadata at /.well-known/oauth-protected-resource/mcp (a 401 from /mcp points there), authorization server metadata at /.well-known/oauth-authorization-server, dynamic client registration at /oauth/register (public clients; redirect URIs https, or http on localhost), the authorization code flow with PKCE (S256 only) and resource = the connector URL, access tokens for an hour and refresh tokens for 30 days (a new one on every refresh), and revocation at /oauth/revoke.

Questions: support@syncdo.co · Help for developers

Questions, answered

What is the Syncdo API?

It lets your own systems create, read and update leads, notes and deals in Syncdo. Webhooks send events back to you as they happen.

Which plan do I need?

Essentials or Intelligence. The workspace owner makes keys under Settings, then API & webhooks.

How do I get a key?

Make a new API key with a name and who gets its leads. It starts with sya_ and is shown once.

Do I need the API for a website form?

No. An inbound webhook URL takes form submissions directly, with no key.

Is there a Zapier or Make app?

Not yet. Their HTTP and webhook steps work with an API key and with webhooks today.

Connect your systems to Syncdo.