SonarSend API Sending Sequences

Sequences

Automated multi-step email flows. The external API is deliberately narrow: you can discover sequences and enrol contacts into them, but the flow itself is authored in the app.

That split is intentional. A sequence graph — branches, waits, conditions — is a design surface, not a data structure worth driving over HTTP. What integrations actually need is “put this person into the onboarding flow”, and that is what this covers.

Required scopes#

EndpointScope
GET /api/sequencessequences:read
GET /api/sequences/{id}sequences:read
GET /api/sequences/contact/{contact_id}/enrollmentssequences:read
POST /api/sequences/{id}/enrollsequences:write

sequences:write implies sequences:read.

Everything else — creating sequences, editing the graph, activating, pausing, bulk enrol, per-enrollment lifecycle actions — is session-only and returns 403 API_KEY_FORBIDDEN to a key.

Enrol a contact#

curl -X POST "https://app.example.com/t/acme/api/sequences/{id}/enroll" \
  -H "X-API-Key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "email": "new@example.com",
        "first_name": "Ada",
        "company": "Example Ltd",
        "idempotency_key": "signup-8f21c"
      }'
{
  "status": "enrolled",
  "enrollment_id": "…",
  "contact_id": "…",
  "contact_created": true
}

contact_created tells you whether this call created the contact or matched an existing one — worth recording if your side needs to know which system saw the person first.

Identifying the contact#

Supply at least one identifier. They are tried in order of precedence — contact_id, then email, then external_id — and the first one present resolves the contact:

FieldUse whenCreates a contact if none matches?
contact_idYou already store our UUIDNo — an unknown id is 404 CONTACT_NOT_FOUND
emailThe usual caseYes
external_idYou key contacts by your own system’s idOnly when email is sent too

Only email can create. When you send email and no contact matches, one is created from it plus any profile fields you send — first_name, last_name, company, custom_fields, tags, stage_id — and if you also sent external_id, that is stamped on the new contact. So a signup webhook can enrol someone who doesn’t exist here yet in a single call.

external_id on its own is a lookup: a contact can’t be created without an email, so a miss is 404 CONTACT_NOT_FOUND. Send email alongside it to get create-if-missing while keying by your own id.

Unknown fields are rejected rather than ignored, so a typo surfaces as a 400 instead of silently dropping data.

Idempotency#

Enrolling a contact who is already actively enrolled returns status: "duplicate" with HTTP 200 — a clean no-op, not an error. You do not need to check first.

For genuine at-least-once delivery, pass an idempotency_key. A retry with the same key is a no-op even if the first attempt’s response never reached you.

Response statuses#

statusHTTPMeaning
enrolled200Newly enrolled
duplicate200Already actively enrolled — no-op

And the failures:

CodeHTTPMeaning
SEQUENCE_NOT_ENROLLABLE422The sequence doesn’t exist, or isn’t active
ENROLL_LIMIT429Enrollment rate limit reached for this contact
MISSING_IDENTIFIER400None of contact_id / email / external_id given
INVALID_INPUT400Unknown field, or a field failed validation

SEQUENCE_NOT_ENROLLABLE is worth handling explicitly: a draft or paused sequence returns it, so an integration pointed at a sequence someone paused in the app fails loudly rather than silently dropping people.

Discover sequences#

GET /api/sequences?status=active&q=onboarding

Returns the tenant’s sequences with their status. Use it to resolve a sequence id at startup instead of hard-coding one — a hard-coded id survives someone rebuilding the flow, which is exactly when you’d want it to fail.

GET /api/sequences/{id}

The sequence with its full node and edge graph. Read-only, and useful mostly for confirming you’re pointed at the flow you think you are.

Check a contact’s enrollments#

GET /api/sequences/contact/{contact_id}/enrollments
{ "data": [ { "sequence_id": "…", "status": "active", "…": "…" } ], "next_cursor": null, "has_more": false }

Every enrollment for that contact across all sequences. This is the “did my enroll land, and where is it now” endpoint — the natural follow-up to an enrol you want to confirm later.

A contact with no enrollments returns an empty data array, not a 404.

What isn’t exposed, and why#

Not availableReason
Create / edit / delete a sequenceThe graph is authored in the app
Activate, pause, archiveOperational state changes with send-side consequences
Bulk enrolLoop the single enrol; it’s idempotent and rate-limited per key
Pause / resume / remove one enrollmentSupport actions, not integration ones
Node test-sendSends real email

If one of these blocks something you’re building, that’s useful signal — the line is drawn at “authoring versus operating”, and it can move.

Errors#

See errors.md for the full envelope, and the rate-limit section for 429 handling — note that ENROLL_LIMIT is a per-contact enrollment guard and a different thing from the per-key request limit, despite sharing the status code.