Skip to content

Workflows

The Workflow API lets your own systems start a Sonora workflow. Name an account and the people at it — by role, email, or contact id — pass a few values for the email to use, and Sonora runs the workflow for that account: it writes and sends the messages, waits for replies, and follows up the way the workflow says. You can then read back every run: which workflows fired for an account or a person, how often, what was sent, and who opened, clicked or replied.

A typical setup: your product-usage pipeline notices an account’s usage dropped, and an n8n flow calls Sonora to email that account’s economic buyer and champion about it.

How keys, scopes, errors and paging work across the whole API is in the overview; every field of every endpoint is in the API reference.

  1. Build an API-triggered workflow. In the workflow builder, set Starts on to From the API. The trigger panel shows the workflow’s id (the one the API takes) and a ready-to-run curl example. Declare any variables the email should use, then write the email with {{trigger.variables.<key>}} tokens where those values go. Activate the workflow when it’s ready; a draft refuses calls.
  2. Choose who it emails. On an email step, the recipients can be People named in the API call — then every call names its recipients. Or use any other recipient option (the account’s contacts, a role, the owner), and a call names only the account.
  3. Pick your role field (if calls will name roles). Under Settings → API → Contact roles, pick the People field that holds each contact’s role — a select-list enrichment such as “Buying role”. Its options are the roles a call can name, each by its slug (Economic Buyer → economic_buyer).
  4. Create an API key with the workflows:trigger scope, plus workflows:read to read runs back — see API keys.

POST /workflows/{workflowId}/runs — scope workflows:trigger.

{workflowId} is the id the builder’s trigger panel (and GET /workflows) shows. It stays the same when the workflow is edited, and a run always uses the latest saved version. Only that id works: the id of one saved version answers 404 workflow_not_found.

Terminal window
curl -X POST https://api.usesonora.com/v1/workflows/4f0c…/runs \
-H "Authorization: Bearer sonora_sk_…" \
-H "Idempotency-Key: usage-alert-8841" \
-H "Content-Type: application/json" \
-d '{
"account": { "id": "001Ab00000XyZ12", "idType": "salesforce" },
"recipients": { "roles": ["economic_buyer", "champion"], "emails": ["cfo@acme.com"] },
"variables": { "reason": "weekly active users dropped 40%" }
}'
Field
account.id The account, as your systems name it. Required.
account.idType How to read account.id: sonora (default — the Sonora account id), salesforce (15- or 18-character Account id), hubspot (Company id), domain (the account’s website domain), or custom_field:<key> (the value of an account custom field).
recipients.roles Role slugs, as GET /contact-roles lists them — "economic_buyer", not "Economic Buyer". Everyone at the account holding the role is included. A value that isn’t a slug answers 422 unknown_role with the valid slugs.
recipients.emails Addresses of the account’s contacts.
recipients.contactIds Sonora contact ids at the account.
variables Values for the workflow’s declared variables: a string (up to 2,000 characters), a number or a boolean.

Any other field is refused with 400 invalid_request. The optional Idempotency-Key header (up to 255 printable characters) makes retries safe — see Idempotency.

recipients is required when the workflow emails People named in the API call, and refused when it doesn’t, so a call can never believe it targeted someone the workflow will ignore. The lists combine: a person named twice is emailed once. A single call reaches at most 25 people.

A named person who can’t be emailed — they unsubscribed, their address bounced, someone on your team marked them do not contact, they have no address, or they were reached recently — is listed in trigger.skippedRecipients instead, with the reason.

202 Accepted — the run has started. The body records what the call started — these fields are named and shaped exactly as GET /workflow-runs/{runId} returns them:

{
"id": "8b6c1f0e-…",
"origin": "api",
"workflow": { "id": "4f0c…", "versionId": "9a21…", "name": "Usage drop outreach" },
"account": { "id": "c1d2…", "name": "Acme" },
"recipients": [
{ "contactId": "…", "email": "jane@acme.com", "name": "Jane Doe",
"matchedBy": [{ "type": "role", "role": "economic_buyer" }] },
{ "contactId": "…", "email": "cfo@acme.com", "name": "Sam Lee",
"matchedBy": [{ "type": "email" }] }
],
"createdAt": "2026-09-20T14:03:11.412Z",
"trigger": {
"requestId": "…",
"receivedAt": "2026-09-20T14:03:11.402Z",
"account": { "id": "001Ab00000XyZ12", "idType": "salesforce" },
"roles": ["economic_buyer", "champion"],
"variables": { "reason": "weekly active users dropped 40%" },
"idempotencyKey": "usage-alert-8841",
"skippedRecipients": [
{ "email": "bob@acme.com", "reason": "recently_triggered",
"matchedBy": [{ "type": "role", "role": "champion" }],
"lastRunId": "…", "retryAfter": "2026-10-20T14:03:11.000Z" }
]
},
"links": { "self": "https://api.usesonora.com/v1/workflow-runs/8b6c1f0e-…" }
}

How the run is going — its status, goal, steps and messages — is not in this body: fetch links.self with a key that has workflows:read.

matchedBy says why each person was included — { "type": "role", "role": "<slug>" }, { "type": "email" } or { "type": "contactId" } — one entry per way the call named them.

The emails go out from the step’s sender — its assignee, or the workflow’s From address — exactly as for any other run, and follow the workflow’s sending settings: review before sending, daily sending limits, and skipping people who already replied. A run whose workflow waits for review sits in the assignee’s queue until someone sends it.

To start a workflow for many accounts at once, POST a JSON array to the same URL — up to 100 elements, each exactly the body of a single call. Scope workflows:trigger.

Terminal window
curl -X POST https://api.usesonora.com/v1/workflows/4f0c…/runs \
-H "Authorization: Bearer sonora_sk_…" \
-H "Idempotency-Key: usage-alerts-2026-09-20" \
-H "Content-Type: application/json" \
-d '[
{ "account": { "id": "001Ab00000XyZ12", "idType": "salesforce" },
"recipients": { "roles": ["economic_buyer"] },
"variables": { "reason": "weekly active users dropped 40%" } },
{ "account": { "id": "001Ab00000QrS34", "idType": "salesforce" },
"recipients": { "roles": ["economic_buyer"] },
"variables": { "reason": "weekly active users dropped 25%" } }
]'

Each element is handled exactly as the same call on its own would be — the same checks, the same throttling, the same errors — so one account failing never holds back another. The answer is 207 Multi-Status with one result per element, in the order you sent them. Each result is the status and body that single call would have answered:

{
"data": [
{ "status": 202, "body": { "id": "8b6c1f0e-…", "origin": "api", "account": { "id": "c1d2…", "name": "Acme" }, "…": "…" } },
{ "status": 409, "body": { "error": { "code": "run_active", "message": "…", "details": { "runId": "…" }, "requestId": "…" } } }
]
}

A 202 result’s body is the new run, as in Start a run; a 200 is a replay (see below); anything else is an error. Check each result’s status — a 207 only says the batch was read.

A batch names each account once: the same account (id and idType) twice is refused. The whole batch is refused, with the usual error and no runs started, only for what is wrong with the request itself — a body that isn’t valid, an empty array or more than 100 elements, a bad key or scope, or an unknown workflow. The body may be up to 1 MB.

Idempotency. One Idempotency-Key header covers the batch. Retrying the batch with the same key replays each account’s run — 200 results, whatever order the elements are in — and starts any account that didn’t start the first time. Change what one account’s element asks for and that element alone answers 422 idempotency_key_mismatch. Each run’s trigger.idempotencyKey shows the batch’s key. A batch and a single call with the same key are different requests: neither replays the other.

Networks fail and automations retry. Send an Idempotency-Key header and a retry of the same call returns the original run — the same body the first call got — with 200 OK and an Idempotent-Replayed: true header, instead of starting a second one. A key is honored for at least 30 days, even after the run finished, and before any other check — a retry never gets a 409.

The key is scoped to the workflow, so the same key can start different workflows. Reusing a key for a different request to the same workflow (another account, recipients or variables) is refused with 422 idempotency_key_mismatch.

In n8n, {{$execution.id}}-{{$itemIndex}} makes a good key: stable across a node’s retries, different for every item. Retrying a whole execution from n8n’s Executions list gives it a new id, so a key built from your own data (an alert or event id) also covers that.

Three things keep an automation from flooding someone:

  • One run per account at a time. While a run of the workflow is active for the account, another call answers 409 run_active with that run’s id. Wait for it to finish.
  • Resend window. A person a call reached is skipped by later calls through the same workflow for the trigger’s resend window — 30 days unless the workflow sets another (0 turns it off). They appear in skippedRecipients with retryAfter. If everyone a call names is inside the window, the call answers 409 recently_triggered with details.retryAfter. The window applies to workflows that email People named in the API call.
  • Your workspace’s sending limits — a workflow’s per-window send limit, spacing between workflows, unsubscribes — apply at send time as they do everywhere else.

Requests are also rate limited: after a 429, wait Retry-After seconds and retry with the same idempotency key.

Scope workflows:read. Runs newest first, across every workflow and however they started — from the API, a trigger, or someone clicking Run — unless you filter. Runs of private workflows are never listed.

Parameter
workflowId Only this workflow’s runs, by the id the builder shows.
accountId, accountIdType Only this account’s runs, identified the same way as when starting one. An account Sonora doesn’t know returns an empty list, not an error.
contactId or contactEmail Only runs an API call started for this person — see below.
origin api, auto (a trigger started it) or manual (someone clicked Run).
status active, completed, failed or cancelled.
createdAfter, createdBefore ISO 8601 date-times with an offset, e.g. 2026-09-01T00:00:00Z.
limit 1–100, default 25.
cursor The previous page’s nextCursor.
include totalCount to add the number of runs matching the filters. Leave it out when you don’t need the count.
{
"data": [
{
"id": "8b6c1f0e-…",
"status": "completed",
"origin": "api",
"workflow": { "id": "4f0c…", "versionId": "9a21…", "name": "Usage drop outreach" },
"account": { "id": "c1d2…", "name": "Acme" },
"recipients": [
{ "contactId": "…", "email": "jane@acme.com", "name": "Jane Doe",
"matchedBy": [{ "type": "role", "role": "economic_buyer" }] }
],
"createdAt": "2026-09-20T14:03:11.412Z",
"completedAt": "2026-09-27T09:00:02.118Z",
"goal": { "name": "Usage recovers", "status": "met", "metAt": "2026-09-25T…", "windowEndsAt": "2026-10-20T…" },
"links": { "self": "https://api.usesonora.com/v1/workflow-runs/8b6c1f0e-…" }
}
],
"nextCursor": "MjAyNi0wOS0yMFQxNDow…"
}

nextCursor is null on the last page. With include=totalCount the response also carries totalCount: every run matching the filters, whichever page you’re on — so ?accountId=…&workflowId=…&limit=1&include=totalCount answers “how many times did we trigger this for Acme” in one call.

account is null for a run of a workflow that runs on people or meetings rather than accounts.

recipients and the person filters come from who each API call named. A run that emailed the account’s contacts rather than People named in the API call has an empty recipients list and isn’t found by contactId or contactEmail.

goal is the workflow’s goal result for the run — pending, met, missed, no_data or already_met — or null when the workflow has no goal.

Scope workflows:read. The run resource: everything POST /workflows/{workflowId}/runs answered with, and everything in the list’s summary, plus:

Field
trigger For an API run: the request id Sonora assigned, when it was received, the account as you named it, the roles that matched (as slugs), the variables, the idempotency key and the skipped recipients. null for other runs.
currentStep { id, label } of the step an active run is on; null once it ends.
waitingOn What an active run is waiting for: review (a draft waiting for someone to send it), send (an automatic email that has not gone out yet, for example one a send limit is holding), reply (the reply window after an email), enrichment (an analysis step’s result), or delay (a wait before the next step). null when it isn’t waiting.
error The latest error the run recorded, if any, as one line. An internal failure reads as a generic message; quote the run id to support for the details.
steps Every step the run visited, in order: id, label, type, status, outcome (the branch it took, e.g. replied), error, enteredAt, endedAt. A workflow that sends to each person separately lists the step once per person, with its own runId.
messages Every email the run produced.

Each message:

{
"stepId": "intro_email", "stepLabel": "Intro email", "cueId": "…",
"contactId": "…", "email": "jane@acme.com",
"status": "sent",
"heldUntil": null, "note": null,
"sentAt": "2026-09-20T14:05:40.000Z",
"trackingScope": "recipient",
"firstOpenedAt": "2026-09-20T15:12:03.000Z", "openCount": 3,
"firstClickedAt": null, "clickCount": 0,
"firstRepliedAt": "2026-09-21T08:40:19.000Z", "replyCount": 1,
"firstBouncedAt": null
}

status is sent, bounced, draft (not sent yet — waiting for review or its send time), held (waiting out a sending limit until heldUntil) or not_sent (the send reached nobody, or the email was closed without sending). An email sent to several people has one message per person. So does an unsent one addressed to the people a call named; an unsent email whose recipients are only worked out when it sends has a single message with contactId and email null.

trackingScope says whose opens and clicks a message’s firstOpenedAt, openCount, firstClickedAt and clickCount are. recipient: this person was the email’s only To recipient. Anyone CC’d or BCC’d on it opens the same email, so their opens and clicks count here too. email: one email went To several people at once. Its opens and clicks are recorded on one of those messages (the first person on its To line) and the others show zero, so they say the email was opened or clicked, not which of its recipients did it. For the email’s totals, add up the messages with trackingScope: "email" that share its cueId; a recipient message is always an email of its own. null until the email is sent. Replies and bounces are always the person’s own.

Open counts are approximate. Sonora leaves out the image loads it can recognise as automatic: a fetch in the first minute after sending (mail providers and security scanners) and Apple Mail Privacy Protection’s preload. Others can still get through, and Apple Mail users’ real opens are hidden behind that preload.

A run that is part of another run (one branch of a workflow that emails each person separately), a private workflow’s run, or an unknown id answers 404 run_not_found.

Scope workflows:read. The workflows you can start through the API — non-private, not deleted, triggered From the API — for filling a dropdown:

{
"data": [
{
"id": "4f0c…",
"name": "Usage drop outreach",
"active": true,
"acceptsRecipients": true,
"variables": [{ "key": "reason", "label": "Reason", "description": null, "required": true }],
"resendWindowHours": 720,
"runsUrl": "https://api.usesonora.com/v1/workflows/4f0c…/runs"
}
]
}

active: false means a call would be refused: the workflow is a draft (409 workflow_inactive until it’s activated), or its author left and it has nobody to send as (409 workflow_has_no_owner). acceptsRecipients says whether a call names its recipients.

Scope workflows:read. The role field and the roles a call can name — by slug; value is the option as people see it in Sonora:

{
"field": { "id": "…", "name": "Buying role" },
"roles": [
{ "value": "Economic Buyer", "slug": "economic_buyer" },
{ "value": "Agent Power User", "slug": "agent_power_user" }
]
}

field is null (and roles empty) until a role field is picked under Settings → API.

A slug comes from the option’s name, so renaming an option changes its slug: a caller still sending the old one gets 422 unknown_role (with the current slugs in details.validRoles) until it’s updated.

Errors use the API’s one error shape. The codes these endpoints add:

Status Code Meaning
404 workflow_not_found, account_not_found, run_not_found No such workflow (or it’s private or deleted, or the id is a version’s rather than the workflow’s), account, or run.
409 run_active A run of this workflow is already active for the account (details.runId).
409 recently_triggered Everyone named is inside the resend window (details.retryAfter).
409 workflow_inactive, workflow_has_no_owner The workflow is a draft, or its author left and it has nobody to send as.
413 payload_too_large The body is over 64 KB (1 MB for a batch).
422 workflow_not_api_triggered The workflow doesn’t start From the API.
422 unknown_variable, missing_variable, invalid_variable A variable the workflow doesn’t declare, a required one missing, or a wrong type or length.
422 account_archived The account is archived.
422 recipients_required, recipients_not_accepted The workflow emails the people a call names and none were named — or the reverse.
422 role_field_not_configured, unknown_role No role field is picked, or a role isn’t one of its slugs (details.validRoles lists them).
422 recipient_not_found, too_many_recipients, no_recipients A named person isn’t a contact at the account, more than 25 people matched, or nobody named can be emailed.
422 idempotency_key_mismatch The key was already used for a different request to this workflow.

Treat 409 run_active and 409 recently_triggered as “already handled” rather than failures: Sonora is already reaching, or recently reached, those people.