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#
| Endpoint | Scope |
|---|---|
GET /api/sequences | sequences:read |
GET /api/sequences/{id} | sequences:read |
GET /api/sequences/contact/{contact_id}/enrollments | sequences:read |
POST /api/sequences/{id}/enroll | sequences: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:
| Field | Use when | Creates a contact if none matches? |
|---|---|---|
contact_id | You already store our UUID | No — an unknown id is 404 CONTACT_NOT_FOUND |
email | The usual case | Yes |
external_id | You key contacts by your own system’s id | Only 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#
status | HTTP | Meaning |
|---|---|---|
enrolled | 200 | Newly enrolled |
duplicate | 200 | Already actively enrolled — no-op |
And the failures:
| Code | HTTP | Meaning |
|---|---|---|
SEQUENCE_NOT_ENROLLABLE | 422 | The sequence doesn’t exist, or isn’t active |
ENROLL_LIMIT | 429 | Enrollment rate limit reached for this contact |
MISSING_IDENTIFIER | 400 | None of contact_id / email / external_id given |
INVALID_INPUT | 400 | Unknown 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 available | Reason |
|---|---|
| Create / edit / delete a sequence | The graph is authored in the app |
| Activate, pause, archive | Operational state changes with send-side consequences |
| Bulk enrol | Loop the single enrol; it’s idempotent and rate-limited per key |
| Pause / resume / remove one enrollment | Support actions, not integration ones |
| Node test-send | Sends 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.