Sequences
The Sequences endpoints.
Auth: API key · Base URL:
https://api.mailkite.dev
Endpoints
Each row expands to its request and response schema. The address bar follows along, so any endpoint can be linked to directly.
GET /v1/sequences List your sequences, newest first, each with live enrollment counts. Archived sequences are omitted.
List your sequences, newest first, each with live enrollment counts. Archived sequences are omitted.
Response body
list-sequences-response.json| Field | Type | Notes |
|---|---|---|
| sequences req | sequence[] | Your sequences, newest first, each with live enrollment counts. Archived ones are omitted. |
| sequences[].id req | string | Sequence id (seq_…). |
| sequences[].name req | string | The handle you reference in send({ sequence }). Unique per account; 1-64 chars of letters, digits, dot, dash, or underscore. |
| sequences[].status req | "draft" · "active" · "paused" · "archived" | Only `active` enrolls. `paused` keeps in-flight contacts parked so unpausing resumes rather than restarts; `archived` retires them. |
| sequences[].from | string · null | Default sender for every send step, on a verified domain. Required once the sequence has an event trigger — an event-triggered enrollment has no message to inherit a sender from. A send that names the sequence always supplies one. |
| sequences[].input | object · null | The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for "anything goes"; you should not need a schema to write a two-step drip. |
| sequences[].triggers | sequence-trigger[] | The doors currently attached. |
| sequences[].triggers[].id req | string | |
| sequences[].triggers[].sequenceId req | string | |
| sequences[].triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| sequences[].triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| sequences[].triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| sequences[].triggers[].createdAt req | number | |
| sequences[].triggers[].updatedAt | number | |
| sequences[].steps req | sequence-step[] | The program, in order. |
| sequences[].steps[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| sequences[].steps[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| sequences[].steps[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| sequences[].steps[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| sequences[].steps[].html | string | send only: HTML body. |
| sequences[].steps[].text | string | send only: plain-text body. |
| sequences[].steps[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| sequences[].steps[].replyTo | string | send only: Reply-To for this step. |
| sequences[].steps[].if | rule | condition only: the rule to evaluate. |
| sequences[].steps[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| sequences[].steps[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| sequences[].steps[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| sequences[].steps[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| sequences[].steps[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| sequences[].steps[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| sequences[].steps[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| sequences[].steps[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| sequences[].steps[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| sequences[].version req | number | Bumped whenever trigger or steps change. Pinned per enrollment. |
| sequences[].reentry | string | `once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. "cooldown:30 days") re-enrolls only when that long has passed since their last enrollment. |
| sequences[].exitOn | object · null | Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it. |
| sequences[].exitOn.goal | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequences[].exitOn.cancel | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequences[].enrollments | object | Live counts keyed by enrollment status, e.g. { "active": 12, "completed": 340 }. |
| sequences[].createdAt req | number | ms epoch. |
| sequences[].updatedAt req | number | ms epoch. |
An actual response, recorded from the conformance suite.
{
"sequences": [
{
"id": "seq_3c8f21aa",
"name": "dunning",
"status": "active",
"from": "billing@acme.dev",
"input": {
"invoiceId": {
"type": "string",
"required": true
}
},
"steps": [
{
"type": "delay",
"for": "3 days"
},
{
"type": "send",
"templateId": "tpl_8Rt5NmZx",
"subject": "Invoice {{input.invoiceId}} is still unpaid"
}
],
"version": 1,
"reentry": "once",
"exitOn": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/list-sequences-response.json",
"title": "List sequences response body",
"type": "object",
"required": [
"sequences"
],
"properties": {
"sequences": {
"type": "array",
"description": "Your sequences, newest first, each with live enrollment counts. Archived ones are omitted.",
"items": {
"$ref": "sequence.json"
}
}
}
} POST /v1/sequences Create a sequence: a declared input shape, the steps a contact walks over time, and zero or more triggers. A sequence is a function — `input` is its signature, and every door must satisfy it, so a step's {{input.field}} means the same thing however it was started. Created as a draft unless you pass status "active". The whole definition is validated up front and every problem is reported at once, so you fix a program in one pass rather than one 400 at a time.
Create a sequence: a declared input shape, the steps a contact walks over time, and zero or more triggers. A sequence is a function — `input` is its signature, and every door must satisfy it, so a step's {{input.field}} means the same thing however it was started. Created as a draft unless you pass status "active". The whole definition is validated up front and every problem is reported at once, so you fix a program in one pass rather than one 400 at a time.
Request body
create-sequence-request.json| Field | Type | Notes |
|---|---|---|
| name req | string | Unique handle for this account; 1-64 chars of letters, digits, dot, dash, or underscore, starting alphanumeric. |
| status | "draft" · "active" · "paused" · "archived" | Defaults to `draft`. A sequence enrolls nobody until it is `active`. |
| from | string | Default sender for every send step, on a verified domain. Required unless every send step carries its own `from`, or trigger.type is "send" (which inherits the triggering message's sender). |
| input | object · null | The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for "anything goes"; you should not need a schema to write a two-step drip. |
| triggers | string · create-trigger-request[] | Doors into this sequence. A bare string is shorthand for { "event": "<name>" }. Replacing the set never bumps the sequence's version and never touches an enrollment in flight. A sequence with no triggers is enrolled only by naming it on a send or calling enroll — which is what "manual" used to mean. |
| steps req | sequence-step[] | At least one step, and at least one of them a `send` — a sequence that never sends is a no-op that still costs a row per contact. |
| steps[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| steps[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| steps[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| steps[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| steps[].html | string | send only: HTML body. |
| steps[].text | string | send only: plain-text body. |
| steps[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| steps[].replyTo | string | send only: Reply-To for this step. |
| steps[].if | rule | condition only: the rule to evaluate. |
| steps[].if.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| steps[].if.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| steps[].if.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| steps[].if.and | rule[] | All of these must hold. |
| steps[].if.or | rule[] | At least one of these must hold. |
| steps[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| steps[].then[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| steps[].then[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| steps[].then[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| steps[].then[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| steps[].then[].html | string | send only: HTML body. |
| steps[].then[].text | string | send only: plain-text body. |
| steps[].then[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| steps[].then[].replyTo | string | send only: Reply-To for this step. |
| steps[].then[].if | rule | condition only: the rule to evaluate. |
| steps[].then[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| steps[].then[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| steps[].then[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| steps[].then[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| steps[].then[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| steps[].then[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| steps[].then[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| steps[].then[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| steps[].then[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| steps[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| steps[].else[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| steps[].else[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| steps[].else[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| steps[].else[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| steps[].else[].html | string | send only: HTML body. |
| steps[].else[].text | string | send only: plain-text body. |
| steps[].else[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| steps[].else[].replyTo | string | send only: Reply-To for this step. |
| steps[].else[].if | rule | condition only: the rule to evaluate. |
| steps[].else[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| steps[].else[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| steps[].else[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| steps[].else[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| steps[].else[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| steps[].else[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| steps[].else[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| steps[].else[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| steps[].else[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| steps[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| steps[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| steps[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| steps[].filter.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| steps[].filter.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| steps[].filter.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| steps[].filter.and | rule[] | All of these must hold. |
| steps[].filter.or | rule[] | At least one of these must hold. |
| steps[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| steps[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| steps[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| steps[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| reentry | string | `once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. "cooldown:30 days") re-enrolls only when that long has passed since their last enrollment. |
| exitOn | object · null | Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it. |
| exitOn.goal | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| exitOn.goal.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| exitOn.goal.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| exitOn.goal.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| exitOn.goal.and | rule[] | All of these must hold. |
| exitOn.goal.or | rule[] | At least one of these must hold. |
| exitOn.cancel | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| exitOn.cancel.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| exitOn.cancel.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| exitOn.cancel.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| exitOn.cancel.and | rule[] | All of these must hold. |
| exitOn.cancel.or | rule[] | At least one of these must hold. |
The smallest body that makes this call — every other field is optional.
{
"name": "dunning",
"status": "active",
"from": "billing@acme.dev",
"input": {
"invoiceId": {
"type": "string",
"required": true
}
},
"triggers": [
"payment.failed",
"invoice.overdue"
],
"steps": [
{
"type": "delay",
"for": "3 days"
},
{
"type": "send",
"templateId": "tpl_8Rt5NmZx",
"subject": "Invoice {{input.invoiceId}} is still unpaid"
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/create-sequence-request.json",
"title": "Create sequence request body",
"type": "object",
"required": [
"name",
"steps"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Unique handle for this account; 1-64 chars of letters, digits, dot, dash, or underscore, starting alphanumeric.",
"examples": [
"dunning"
]
},
"status": {
"type": "string",
"enum": [
"draft",
"active",
"paused",
"archived"
],
"description": "Defaults to `draft`. A sequence enrolls nobody until it is `active`."
},
"from": {
"type": "string",
"description": "Default sender for every send step, on a verified domain. Required unless every send step carries its own `from`, or trigger.type is \"send\" (which inherits the triggering message's sender)."
},
"input": {
"type": [
"object",
"null"
],
"description": "The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for \"anything goes\"; you should not need a schema to write a two-step drip.",
"additionalProperties": {
"$ref": "input-field.json"
},
"examples": [
{
"invoiceId": {
"type": "string",
"required": true
},
"amountDue": {
"type": "number"
}
}
]
},
"triggers": {
"type": "array",
"description": "Doors into this sequence. A bare string is shorthand for { \"event\": \"<name>\" }. Replacing the set never bumps the sequence's version and never touches an enrollment in flight. A sequence with no triggers is enrolled only by naming it on a send or calling enroll — which is what \"manual\" used to mean.",
"items": {
"oneOf": [
{
"type": "string"
},
{
"$ref": "create-trigger-request.json"
}
]
},
"examples": [
[
"payment.failed",
"invoice.overdue"
]
]
},
"steps": {
"type": "array",
"minItems": 1,
"description": "At least one step, and at least one of them a `send` — a sequence that never sends is a no-op that still costs a row per contact.",
"items": {
"$ref": "sequence-step.json"
}
},
"reentry": {
"type": "string",
"description": "`once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. \"cooldown:30 days\") re-enrolls only when that long has passed since their last enrollment.",
"examples": [
"once",
"always",
"cooldown:30 days"
]
},
"exitOn": {
"type": [
"object",
"null"
],
"description": "Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it.",
"properties": {
"goal": {
"$ref": "rule.json"
},
"cancel": {
"$ref": "rule.json"
}
}
}
}
} Response body
sequence-response.json| Field | Type | Notes |
|---|---|---|
| sequence req | sequence | An automated series of emails: zero or more triggers, a declared input shape, then steps a contact walks over time. Only an `active` sequence enrolls anyone. Editing the STEPS bumps `version` and contacts already in flight keep walking the version they started on, so an edit can never make someone skip or repeat a step; nothing else about a sequence — a rename, a pause, its signature, its triggers — re-pins anybody. |
| sequence.id req | string | Sequence id (seq_…). |
| sequence.name req | string | The handle you reference in send({ sequence }). Unique per account; 1-64 chars of letters, digits, dot, dash, or underscore. |
| sequence.status req | "draft" · "active" · "paused" · "archived" | Only `active` enrolls. `paused` keeps in-flight contacts parked so unpausing resumes rather than restarts; `archived` retires them. |
| sequence.from | string · null | Default sender for every send step, on a verified domain. Required once the sequence has an event trigger — an event-triggered enrollment has no message to inherit a sender from. A send that names the sequence always supplies one. |
| sequence.input | object · null | The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for "anything goes"; you should not need a schema to write a two-step drip. |
| sequence.triggers | sequence-trigger[] | The doors currently attached. |
| sequence.triggers[].id req | string | |
| sequence.triggers[].sequenceId req | string | |
| sequence.triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| sequence.triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| sequence.triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| sequence.triggers[].createdAt req | number | |
| sequence.triggers[].updatedAt | number | |
| sequence.steps req | sequence-step[] | The program, in order. |
| sequence.steps[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| sequence.steps[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| sequence.steps[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| sequence.steps[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| sequence.steps[].html | string | send only: HTML body. |
| sequence.steps[].text | string | send only: plain-text body. |
| sequence.steps[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| sequence.steps[].replyTo | string | send only: Reply-To for this step. |
| sequence.steps[].if | rule | condition only: the rule to evaluate. |
| sequence.steps[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| sequence.steps[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| sequence.steps[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| sequence.steps[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| sequence.steps[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| sequence.steps[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| sequence.steps[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| sequence.steps[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| sequence.steps[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| sequence.version req | number | Bumped whenever trigger or steps change. Pinned per enrollment. |
| sequence.reentry | string | `once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. "cooldown:30 days") re-enrolls only when that long has passed since their last enrollment. |
| sequence.exitOn | object · null | Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it. |
| sequence.exitOn.goal | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequence.exitOn.cancel | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequence.enrollments | object | Live counts keyed by enrollment status, e.g. { "active": 12, "completed": 340 }. |
| sequence.createdAt req | number | ms epoch. |
| sequence.updatedAt req | number | ms epoch. |
| enrollments | object | Live counts keyed by enrollment status. |
| triggers | sequence-trigger[] | |
| triggers[].id req | string | |
| triggers[].sequenceId req | string | |
| triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| triggers[].createdAt req | number | |
| triggers[].updatedAt | number |
An actual response, recorded from the conformance suite.
{
"sequence": {
"id": "seq_3c8f21aa",
"name": "dunning",
"status": "active",
"from": "billing@acme.dev",
"input": {
"invoiceId": {
"type": "string",
"required": true
}
},
"steps": [
{
"type": "delay",
"for": "3 days"
},
{
"type": "send",
"templateId": "tpl_8Rt5NmZx",
"subject": "Invoice {{input.invoiceId}} is still unpaid"
}
],
"version": 1,
"reentry": "once",
"exitOn": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
},
"triggers": [
{
"id": "trg_5f1c22ab",
"sequenceId": "seq_3c8f21aa",
"event": "payment.failed",
"filter": null,
"enabled": true,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/sequence-response.json",
"title": "Sequence response body",
"type": "object",
"required": [
"sequence"
],
"properties": {
"sequence": {
"$ref": "sequence.json"
},
"enrollments": {
"type": "object",
"description": "Live counts keyed by enrollment status."
},
"triggers": {
"type": "array",
"items": {
"$ref": "sequence-trigger.json"
}
}
}
} GET /v1/sequences/:id Get one sequence with its definition and live enrollment counts.
Get one sequence with its definition and live enrollment counts.
Response body
sequence-response.json| Field | Type | Notes |
|---|---|---|
| sequence req | sequence | An automated series of emails: zero or more triggers, a declared input shape, then steps a contact walks over time. Only an `active` sequence enrolls anyone. Editing the STEPS bumps `version` and contacts already in flight keep walking the version they started on, so an edit can never make someone skip or repeat a step; nothing else about a sequence — a rename, a pause, its signature, its triggers — re-pins anybody. |
| sequence.id req | string | Sequence id (seq_…). |
| sequence.name req | string | The handle you reference in send({ sequence }). Unique per account; 1-64 chars of letters, digits, dot, dash, or underscore. |
| sequence.status req | "draft" · "active" · "paused" · "archived" | Only `active` enrolls. `paused` keeps in-flight contacts parked so unpausing resumes rather than restarts; `archived` retires them. |
| sequence.from | string · null | Default sender for every send step, on a verified domain. Required once the sequence has an event trigger — an event-triggered enrollment has no message to inherit a sender from. A send that names the sequence always supplies one. |
| sequence.input | object · null | The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for "anything goes"; you should not need a schema to write a two-step drip. |
| sequence.triggers | sequence-trigger[] | The doors currently attached. |
| sequence.triggers[].id req | string | |
| sequence.triggers[].sequenceId req | string | |
| sequence.triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| sequence.triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| sequence.triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| sequence.triggers[].createdAt req | number | |
| sequence.triggers[].updatedAt | number | |
| sequence.steps req | sequence-step[] | The program, in order. |
| sequence.steps[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| sequence.steps[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| sequence.steps[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| sequence.steps[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| sequence.steps[].html | string | send only: HTML body. |
| sequence.steps[].text | string | send only: plain-text body. |
| sequence.steps[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| sequence.steps[].replyTo | string | send only: Reply-To for this step. |
| sequence.steps[].if | rule | condition only: the rule to evaluate. |
| sequence.steps[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| sequence.steps[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| sequence.steps[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| sequence.steps[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| sequence.steps[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| sequence.steps[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| sequence.steps[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| sequence.steps[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| sequence.steps[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| sequence.version req | number | Bumped whenever trigger or steps change. Pinned per enrollment. |
| sequence.reentry | string | `once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. "cooldown:30 days") re-enrolls only when that long has passed since their last enrollment. |
| sequence.exitOn | object · null | Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it. |
| sequence.exitOn.goal | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequence.exitOn.cancel | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequence.enrollments | object | Live counts keyed by enrollment status, e.g. { "active": 12, "completed": 340 }. |
| sequence.createdAt req | number | ms epoch. |
| sequence.updatedAt req | number | ms epoch. |
| enrollments | object | Live counts keyed by enrollment status. |
| triggers | sequence-trigger[] | |
| triggers[].id req | string | |
| triggers[].sequenceId req | string | |
| triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| triggers[].createdAt req | number | |
| triggers[].updatedAt | number |
An actual response, recorded from the conformance suite.
{
"sequence": {
"id": "seq_3c8f21aa",
"name": "dunning",
"status": "active",
"from": "billing@acme.dev",
"input": {
"invoiceId": {
"type": "string",
"required": true
}
},
"steps": [
{
"type": "delay",
"for": "3 days"
},
{
"type": "send",
"templateId": "tpl_8Rt5NmZx",
"subject": "Invoice {{input.invoiceId}} is still unpaid"
}
],
"version": 1,
"reentry": "once",
"exitOn": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
},
"triggers": [
{
"id": "trg_5f1c22ab",
"sequenceId": "seq_3c8f21aa",
"event": "payment.failed",
"filter": null,
"enabled": true,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
],
"enrollments": {
"active": 12,
"completed": 340
}
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/sequence-response.json",
"title": "Sequence response body",
"type": "object",
"required": [
"sequence"
],
"properties": {
"sequence": {
"$ref": "sequence.json"
},
"enrollments": {
"type": "object",
"description": "Live counts keyed by enrollment status."
},
"triggers": {
"type": "array",
"items": {
"$ref": "sequence-trigger.json"
}
}
}
} PATCH /v1/sequences/:id Edit a sequence. Changing the STEPS bumps its version and contacts already in flight keep walking the version they started on, so an edit can never make someone skip or repeat a step. Nothing else re-pins anybody — a rename, a pause, the input signature, and the trigger set are all editable while people are mid-flight. Archiving retires everyone in flight; pausing deliberately does not, so unpausing resumes rather than restarts.
Edit a sequence. Changing the STEPS bumps its version and contacts already in flight keep walking the version they started on, so an edit can never make someone skip or repeat a step. Nothing else re-pins anybody — a rename, a pause, the input signature, and the trigger set are all editable while people are mid-flight. Archiving retires everyone in flight; pausing deliberately does not, so unpausing resumes rather than restarts.
Request body
update-sequence-request.jsonEvery field is optional; omitted fields are left alone. Changing `trigger` or `steps` bumps the sequence's version, and contacts already in flight keep walking the version they started on.
| Field | Type | Notes |
|---|---|---|
| name | string | |
| status | "draft" · "active" · "paused" · "archived" | |
| from | string | |
| input | object · null | The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for "anything goes"; you should not need a schema to write a two-step drip. |
| triggers | string · create-trigger-request[] | Doors into this sequence. A bare string is shorthand for { "event": "<name>" }. Replacing the set never bumps the sequence's version and never touches an enrollment in flight. A sequence with no triggers is enrolled only by naming it on a send or calling enroll — which is what "manual" used to mean. |
| steps | sequence-step[] | |
| steps[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| steps[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| steps[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| steps[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| steps[].html | string | send only: HTML body. |
| steps[].text | string | send only: plain-text body. |
| steps[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| steps[].replyTo | string | send only: Reply-To for this step. |
| steps[].if | rule | condition only: the rule to evaluate. |
| steps[].if.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| steps[].if.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| steps[].if.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| steps[].if.and | rule[] | All of these must hold. |
| steps[].if.or | rule[] | At least one of these must hold. |
| steps[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| steps[].then[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| steps[].then[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| steps[].then[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| steps[].then[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| steps[].then[].html | string | send only: HTML body. |
| steps[].then[].text | string | send only: plain-text body. |
| steps[].then[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| steps[].then[].replyTo | string | send only: Reply-To for this step. |
| steps[].then[].if | rule | condition only: the rule to evaluate. |
| steps[].then[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| steps[].then[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| steps[].then[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| steps[].then[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| steps[].then[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| steps[].then[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| steps[].then[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| steps[].then[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| steps[].then[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| steps[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| steps[].else[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| steps[].else[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| steps[].else[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| steps[].else[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| steps[].else[].html | string | send only: HTML body. |
| steps[].else[].text | string | send only: plain-text body. |
| steps[].else[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| steps[].else[].replyTo | string | send only: Reply-To for this step. |
| steps[].else[].if | rule | condition only: the rule to evaluate. |
| steps[].else[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| steps[].else[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| steps[].else[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| steps[].else[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| steps[].else[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| steps[].else[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| steps[].else[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| steps[].else[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| steps[].else[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| steps[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| steps[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| steps[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| steps[].filter.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| steps[].filter.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| steps[].filter.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| steps[].filter.and | rule[] | All of these must hold. |
| steps[].filter.or | rule[] | At least one of these must hold. |
| steps[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| steps[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| steps[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| steps[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| reentry | string | `once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. "cooldown:30 days") re-enrolls only when that long has passed since their last enrollment. |
| exitOn | object · null | Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it. |
| exitOn.goal | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| exitOn.goal.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| exitOn.goal.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| exitOn.goal.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| exitOn.goal.and | rule[] | All of these must hold. |
| exitOn.goal.or | rule[] | At least one of these must hold. |
| exitOn.cancel | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| exitOn.cancel.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| exitOn.cancel.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| exitOn.cancel.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| exitOn.cancel.and | rule[] | All of these must hold. |
| exitOn.cancel.or | rule[] | At least one of these must hold. |
The smallest body that makes this call — every other field is optional.
{
"status": "paused"
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/update-sequence-request.json",
"title": "Update sequence request body",
"description": "Every field is optional; omitted fields are left alone. Changing `trigger` or `steps` bumps the sequence's version, and contacts already in flight keep walking the version they started on.",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"draft",
"active",
"paused",
"archived"
]
},
"from": {
"type": "string"
},
"input": {
"type": [
"object",
"null"
],
"description": "The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for \"anything goes\"; you should not need a schema to write a two-step drip.",
"additionalProperties": {
"$ref": "input-field.json"
},
"examples": [
{
"invoiceId": {
"type": "string",
"required": true
},
"amountDue": {
"type": "number"
}
}
]
},
"triggers": {
"type": "array",
"description": "Doors into this sequence. A bare string is shorthand for { \"event\": \"<name>\" }. Replacing the set never bumps the sequence's version and never touches an enrollment in flight. A sequence with no triggers is enrolled only by naming it on a send or calling enroll — which is what \"manual\" used to mean.",
"items": {
"oneOf": [
{
"type": "string"
},
{
"$ref": "create-trigger-request.json"
}
]
},
"examples": [
[
"payment.failed",
"invoice.overdue"
]
]
},
"steps": {
"type": "array",
"items": {
"$ref": "sequence-step.json"
}
},
"reentry": {
"type": "string",
"description": "`once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. \"cooldown:30 days\") re-enrolls only when that long has passed since their last enrollment.",
"examples": [
"once",
"always",
"cooldown:30 days"
]
},
"exitOn": {
"type": [
"object",
"null"
],
"description": "Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it.",
"properties": {
"goal": {
"$ref": "rule.json"
},
"cancel": {
"$ref": "rule.json"
}
}
}
}
} Response body
sequence-response.json| Field | Type | Notes |
|---|---|---|
| sequence req | sequence | An automated series of emails: zero or more triggers, a declared input shape, then steps a contact walks over time. Only an `active` sequence enrolls anyone. Editing the STEPS bumps `version` and contacts already in flight keep walking the version they started on, so an edit can never make someone skip or repeat a step; nothing else about a sequence — a rename, a pause, its signature, its triggers — re-pins anybody. |
| sequence.id req | string | Sequence id (seq_…). |
| sequence.name req | string | The handle you reference in send({ sequence }). Unique per account; 1-64 chars of letters, digits, dot, dash, or underscore. |
| sequence.status req | "draft" · "active" · "paused" · "archived" | Only `active` enrolls. `paused` keeps in-flight contacts parked so unpausing resumes rather than restarts; `archived` retires them. |
| sequence.from | string · null | Default sender for every send step, on a verified domain. Required once the sequence has an event trigger — an event-triggered enrollment has no message to inherit a sender from. A send that names the sequence always supplies one. |
| sequence.input | object · null | The sequence's input signature: the shape every door must supply. A sequence is a function and this is how you declare its parameters — steps then read them as {{input.field}}, and a condition can branch on them. Omit it entirely for "anything goes"; you should not need a schema to write a two-step drip. |
| sequence.triggers | sequence-trigger[] | The doors currently attached. |
| sequence.triggers[].id req | string | |
| sequence.triggers[].sequenceId req | string | |
| sequence.triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| sequence.triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| sequence.triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| sequence.triggers[].createdAt req | number | |
| sequence.triggers[].updatedAt | number | |
| sequence.steps req | sequence-step[] | The program, in order. |
| sequence.steps[].type req | "delay" · "send" · "exit" · "condition" · "wait_for_event" · "branch_ai" | Which step this is. |
| sequence.steps[].for | string | delay only: how long to wait — "3 days", "90 minutes", "2h". Maximum 30 days per step. |
| sequence.steps[].templateId | string | send only: a saved template (tpl_…) or base template (base_…) supplying subject/html/text. Explicit fields below override it. |
| sequence.steps[].subject | string | send only: subject line. Required unless a templateId supplies one. Merge tags are resolved at fire time. |
| sequence.steps[].html | string | send only: HTML body. |
| sequence.steps[].text | string | send only: plain-text body. |
| sequence.steps[].from | string | send only: override the sequence's sender for this step. Must be on a verified domain. |
| sequence.steps[].replyTo | string | send only: Reply-To for this step. |
| sequence.steps[].if | rule | condition only: the rule to evaluate. |
| sequence.steps[].then | sequence-step[] | condition: steps to run when the rule holds. wait_for_event: steps to run when the event arrives. Give a wait neither arm and both outcomes simply continue to the next step. |
| sequence.steps[].else | sequence-step[] | condition: steps to run when the rule does not hold — a condition needs at least one of `then` or `else`. wait_for_event: steps to run when the wait times out instead. |
| sequence.steps[].event | string | wait_for_event only: the event name to wait for. Sent to POST /v1/events. |
| sequence.steps[].timeout | string | wait_for_event only: how long to wait before giving up. Omitted = 30 days, the maximum. Timing out takes the `else` arm, or — with no arms — simply continues to the next step. That direction is deliberate: the follow-up is the default and the event is the escape, so a never-sent event cannot silently cancel a campaign. |
| sequence.steps[].filter | rule | wait_for_event only: wake only on an occurrence whose payload also satisfies this. |
| sequence.steps[].prompt | string | branch_ai only: what to decide. The contact and the trigger payload are supplied as context automatically — the model sees exactly what a condition or a merge tag would. Max 2000 characters. |
| sequence.steps[].choices | string[] | branch_ai only: the allowed answers, two to six of them. The model must answer with exactly one; anything else is treated as untrusted and `default` is taken. |
| sequence.steps[].default | string | branch_ai only, REQUIRED: which choice to take when the model is unavailable, slow, refuses, or answers something not on the list. This is what makes an AI step deterministic when the AI is not there. |
| sequence.steps[].branches | object | branch_ai only: steps per choice, keyed by choice name. A choice with no branch simply continues to the next step. |
| sequence.version req | number | Bumped whenever trigger or steps change. Pinned per enrollment. |
| sequence.reentry | string | `once` (default) refuses to enroll a contact already in flight. `always` restarts them: the in-flight enrollment is canceled and a new one begins. `cooldown:<duration>` (e.g. "cooldown:30 days") re-enrolls only when that long has passed since their last enrollment. |
| sequence.exitOn | object · null | Sequence-level exits, evaluated before EVERY step. `goal` completes the enrollment — they did the thing, stop chasing, which is the difference between a dunning sequence and harassment. `cancel` abandons it. |
| sequence.exitOn.goal | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequence.exitOn.cancel | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| sequence.enrollments | object | Live counts keyed by enrollment status, e.g. { "active": 12, "completed": 340 }. |
| sequence.createdAt req | number | ms epoch. |
| sequence.updatedAt req | number | ms epoch. |
| enrollments | object | Live counts keyed by enrollment status. |
| triggers | sequence-trigger[] | |
| triggers[].id req | string | |
| triggers[].sequenceId req | string | |
| triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| triggers[].createdAt req | number | |
| triggers[].updatedAt | number |
An actual response, recorded from the conformance suite.
{
"sequence": {
"id": "seq_3c8f21aa",
"name": "dunning",
"status": "active",
"from": "billing@acme.dev",
"input": {
"invoiceId": {
"type": "string",
"required": true
}
},
"steps": [
{
"type": "delay",
"for": "3 days"
},
{
"type": "send",
"templateId": "tpl_8Rt5NmZx",
"subject": "Invoice {{input.invoiceId}} is still unpaid"
}
],
"version": 1,
"reentry": "once",
"exitOn": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
},
"triggers": [
{
"id": "trg_5f1c22ab",
"sequenceId": "seq_3c8f21aa",
"event": "payment.failed",
"filter": null,
"enabled": true,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/sequence-response.json",
"title": "Sequence response body",
"type": "object",
"required": [
"sequence"
],
"properties": {
"sequence": {
"$ref": "sequence.json"
},
"enrollments": {
"type": "object",
"description": "Live counts keyed by enrollment status."
},
"triggers": {
"type": "array",
"items": {
"$ref": "sequence-trigger.json"
}
}
}
} DELETE /v1/sequences/:id Delete a sequence and retire every contact still walking it. The response reports how many were canceled.
Delete a sequence and retire every contact still walking it. The response reports how many were canceled.
Response body
delete-sequence-response.json| Field | Type | Notes |
|---|---|---|
| id req | string | |
| deleted req | boolean | |
| enrollmentsCanceled | number | How many in-flight contacts were retired by the delete. |
An actual response, recorded from the conformance suite.
{
"id": "seq_3c8f21aa",
"deleted": true,
"enrollmentsCanceled": 12
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/delete-sequence-response.json",
"title": "Delete sequence response body",
"type": "object",
"required": [
"id",
"deleted"
],
"properties": {
"id": {
"type": "string",
"examples": [
"seq_3c8f21aa"
]
},
"deleted": {
"type": "boolean"
},
"enrollmentsCanceled": {
"type": "number",
"description": "How many in-flight contacts were retired by the delete."
}
}
} GET /v1/sequences/:id/triggers List the triggers attached to a sequence — the doors into it.
List the triggers attached to a sequence — the doors into it.
Response body
list-triggers-response.json| Field | Type | Notes |
|---|---|---|
| triggers req | sequence-trigger[] | |
| triggers[].id req | string | |
| triggers[].sequenceId req | string | |
| triggers[].event req | string | The event name to match, as posted to POST /v1/events. |
| triggers[].filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| triggers[].enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| triggers[].createdAt req | number | |
| triggers[].updatedAt | number |
An actual response, recorded from the conformance suite.
{
"triggers": [
{
"id": "trg_5f1c22ab",
"sequenceId": "seq_3c8f21aa",
"event": "payment.failed",
"filter": null,
"enabled": true,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/list-triggers-response.json",
"title": "List triggers response body",
"type": "object",
"required": [
"triggers"
],
"properties": {
"triggers": {
"type": "array",
"items": {
"$ref": "sequence-trigger.json"
}
}
}
} POST /v1/sequences/:id/triggers Attach a trigger: when this event arrives, enroll the contact it is about. Attaching never bumps the sequence's version and never touches anyone already in flight — a trigger is a fact about the outside world, a sequence is a program, and they change on different rhythms. The sequence needs a `from` address first, since an event-triggered enrollment has no message to inherit a sender from.
Attach a trigger: when this event arrives, enroll the contact it is about. Attaching never bumps the sequence's version and never touches anyone already in flight — a trigger is a fact about the outside world, a sequence is a program, and they change on different rhythms. The sequence needs a `from` address first, since an event-triggered enrollment has no message to inherit a sender from.
Request body
create-trigger-request.jsonAttach a door to a sequence. The sequence must have a `from` address: an event-triggered enrollment has no message to inherit a sender from. One trigger per (sequence, event) — a duplicate would enroll twice, and `reentry: "once"` would absorb the second silently.
| Field | Type | Notes |
|---|---|---|
| event req | string | |
| filter | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| filter.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| filter.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| filter.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| filter.and | rule[] | All of these must hold. |
| filter.and[].field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| filter.and[].op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| filter.and[].value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| filter.and[].and | rule[] | All of these must hold. |
| filter.and[].or | rule[] | At least one of these must hold. |
| filter.or | rule[] | At least one of these must hold. |
| filter.or[].field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| filter.or[].op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| filter.or[].value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| filter.or[].and | rule[] | All of these must hold. |
| filter.or[].or | rule[] | At least one of these must hold. |
| enabled | boolean | Defaults to true. |
The smallest body that makes this call — every other field is optional.
{
"event": "payment.failed"
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/create-trigger-request.json",
"title": "Create trigger request body",
"description": "Attach a door to a sequence. The sequence must have a `from` address: an event-triggered enrollment has no message to inherit a sender from. One trigger per (sequence, event) — a duplicate would enroll twice, and `reentry: \"once\"` would absorb the second silently.",
"type": "object",
"required": [
"event"
],
"additionalProperties": false,
"properties": {
"event": {
"type": "string",
"examples": [
"payment.failed"
]
},
"filter": {
"$ref": "rule.json"
},
"enabled": {
"type": "boolean",
"description": "Defaults to true."
}
}
} Response body
trigger-response.json| Field | Type | Notes |
|---|---|---|
| trigger req | sequence-trigger | A door into a sequence: when this event arrives, enroll the contact it is about. Its own resource, because a trigger is a fact about the outside world while a sequence is a program — they change on completely different rhythms. Attaching, detaching, enabling, or disabling one never bumps the sequence's version and never touches an enrollment already in flight. |
| trigger.id req | string | |
| trigger.sequenceId req | string | |
| trigger.event req | string | The event name to match, as posted to POST /v1/events. |
| trigger.filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| trigger.enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| trigger.createdAt req | number | |
| trigger.updatedAt | number |
An actual response, recorded from the conformance suite.
{
"trigger": {
"id": "trg_5f1c22ab",
"sequenceId": "seq_3c8f21aa",
"event": "payment.failed",
"filter": null,
"enabled": true,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/trigger-response.json",
"title": "Trigger response body",
"type": "object",
"required": [
"trigger"
],
"properties": {
"trigger": {
"$ref": "sequence-trigger.json"
}
}
} PATCH /v1/triggers/:id Edit a trigger, or toggle `enabled` to switch the door off without deleting it. Either way, everyone already walking the sequence carries on.
Edit a trigger, or toggle `enabled` to switch the door off without deleting it. Either way, everyone already walking the sequence carries on.
Request body
update-trigger-request.jsonEvery field optional. Toggling `enabled` is the ad-hoc lever: it stops future enrollments immediately and leaves everyone in flight alone.
| Field | Type | Notes |
|---|---|---|
| event | string | |
| filter | rule | A predicate over the enrolled contact and the trigger payload. Either a comparison — `field`, `op`, and (for binary operators) `value` — or a group: `{ "and": [...] }` / `{ "or": [...] }`. Fields live in two namespaces, `event.*` (the trigger payload) and `contact.*` (stored contact properties), plus bare `email` and `name`. This is the same operator set and shape used by Resend, Loops, and Customer.io, so an automation written against one of those ports across unchanged. A missing field is falsy for every operator except `exists` (false) and `is_empty` (true). |
| filter.field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| filter.op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| filter.value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| filter.and | rule[] | All of these must hold. |
| filter.and[].field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| filter.and[].op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| filter.and[].value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| filter.and[].and | rule[] | All of these must hold. |
| filter.and[].or | rule[] | At least one of these must hold. |
| filter.or | rule[] | At least one of these must hold. |
| filter.or[].field | string | Dotted path into the context, e.g. "event.plan" or "contact.company". |
| filter.or[].op | "eq" · "neq" · "gt" · "gte" · "lt" · "lte" · "contains" · "starts_with" · "ends_with" · "exists" · "is_empty" | Comparison operator. `exists` and `is_empty` take no `value`. |
| filter.or[].value | string · number · boolean · null | What to compare against. String and number are compared loosely, so a payload carrying "3" still matches a rule written against 3. |
| filter.or[].and | rule[] | All of these must hold. |
| filter.or[].or | rule[] | At least one of these must hold. |
| enabled | boolean |
The smallest body that makes this call — every other field is optional.
{
"enabled": false
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/update-trigger-request.json",
"title": "Update trigger request body",
"description": "Every field optional. Toggling `enabled` is the ad-hoc lever: it stops future enrollments immediately and leaves everyone in flight alone.",
"type": "object",
"additionalProperties": false,
"properties": {
"event": {
"type": "string"
},
"filter": {
"$ref": "rule.json"
},
"enabled": {
"type": "boolean"
}
}
} Response body
trigger-response.json| Field | Type | Notes |
|---|---|---|
| trigger req | sequence-trigger | A door into a sequence: when this event arrives, enroll the contact it is about. Its own resource, because a trigger is a fact about the outside world while a sequence is a program — they change on completely different rhythms. Attaching, detaching, enabling, or disabling one never bumps the sequence's version and never touches an enrollment already in flight. |
| trigger.id req | string | |
| trigger.sequenceId req | string | |
| trigger.event req | string | The event name to match, as posted to POST /v1/events. |
| trigger.filter | object · null | Only open the door when the event's payload also satisfies this rule — "payment.failed, but only over $50". Null means every occurrence opens it. Shape: see the `rule` schema. |
| trigger.enabled req | boolean | Switch the door off without deleting or editing anything. A disabled trigger enrolls nobody; everyone already in flight carries on. |
| trigger.createdAt req | number | |
| trigger.updatedAt | number |
An actual response, recorded from the conformance suite.
{
"trigger": {
"id": "trg_5f1c22ab",
"sequenceId": "seq_3c8f21aa",
"event": "payment.failed",
"filter": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000,
"enabled": false
}
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/trigger-response.json",
"title": "Trigger response body",
"type": "object",
"required": [
"trigger"
],
"properties": {
"trigger": {
"$ref": "sequence-trigger.json"
}
}
} DELETE /v1/triggers/:id Detach a trigger. Stops future enrollments through that door and nothing else.
Detach a trigger. Stops future enrollments through that door and nothing else.
Response body
delete-trigger-response.json| Field | Type | Notes |
|---|---|---|
| id req | string | |
| deleted req | boolean |
An actual response, recorded from the conformance suite.
{
"id": "trg_5f1c22ab",
"deleted": true
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/delete-trigger-response.json",
"title": "Delete trigger response body",
"type": "object",
"required": [
"id",
"deleted"
],
"properties": {
"id": {
"type": "string"
},
"deleted": {
"type": "boolean"
}
}
} POST /v1/sequences/:id/enroll Put one contact into an active sequence by hand, with the input its signature declares. Refused with 409 when the address is suppressed, already in flight on a `reentry: "once"` sequence, or missing a required input — and the response says which. Pass `cancelKey` to be able to cancel later using your own identifier.
Put one contact into an active sequence by hand, with the input its signature declares. Refused with 409 when the address is suppressed, already in flight on a `reentry: "once"` sequence, or missing a required input — and the response says which. Pass `cancelKey` to be able to cancel later using your own identifier.
Request body
enroll-request.jsonPut one contact into a sequence by hand. The sequence must be `active`. A suppressed address, or one already in flight on a `reentry: "once"` sequence, is refused with 409 rather than silently ignored.
| Field | Type | Notes |
|---|---|---|
| string | The address to enroll. Give this or `contactId`. | |
| contactId | string | A contact you own (ctc_…), as an alternative to `email`. |
| from | string | Sender for this enrollment, when the sequence has no default (a send-triggered sequence normally inherits one from the triggering message, which a manual enroll does not have). |
| input | object | The sequence's input, checked against its declared signature. A missing required field refuses the enrollment and says which — you find out here rather than days later as a blank merge tag. |
| data | object | The original name for `input`. Still accepted; `input` wins when both are given. |
| cancelKey | string | Your own key for this enrollment. Cancel later with POST /v1/enrollments/cancel and this key — no need to store our id. |
The smallest body that makes this call — every other field is optional.
{
"email": "ada@example.com",
"input": {
"invoiceId": "inv_1042"
},
"cancelKey": "invoice_9f2c"
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/enroll-request.json",
"title": "Enroll request body",
"description": "Put one contact into a sequence by hand. The sequence must be `active`. A suppressed address, or one already in flight on a `reentry: \"once\"` sequence, is refused with 409 rather than silently ignored.",
"type": "object",
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
"description": "The address to enroll. Give this or `contactId`.",
"examples": [
"ada@example.com"
]
},
"contactId": {
"type": "string",
"description": "A contact you own (ctc_…), as an alternative to `email`.",
"examples": [
"ctc_8Rt5NmZx"
]
},
"from": {
"type": "string",
"description": "Sender for this enrollment, when the sequence has no default (a send-triggered sequence normally inherits one from the triggering message, which a manual enroll does not have)."
},
"input": {
"type": "object",
"description": "The sequence's input, checked against its declared signature. A missing required field refuses the enrollment and says which — you find out here rather than days later as a blank merge tag."
},
"data": {
"type": "object",
"description": "The original name for `input`. Still accepted; `input` wins when both are given."
},
"cancelKey": {
"type": "string",
"description": "Your own key for this enrollment. Cancel later with POST /v1/enrollments/cancel and this key — no need to store our id.",
"examples": [
"invoice_9f2c"
]
}
}
} Response body
enrollment-response.json| Field | Type | Notes |
|---|---|---|
| enrollment req | enrollment | One contact walking one sequence — where they are, and what happens next. `active` means a step is due at `nextAt`; `waiting` means parked on an event; `completed`, `canceled`, and `failed` are terminal. |
| enrollment.id req | string | Enrollment id (enr_…). |
| enrollment.sequenceId req | string | |
| enrollment.sequenceVersion | number | The sequence version this contact is walking, pinned at enrollment. |
| enrollment.email req | string | The enrolled address, normalized to lowercase. |
| enrollment.contactId | string · null | The contact this resolved to, when the address is one you hold. |
| enrollment.from | string | The sender frozen at enrollment — send steps use it unless they override with their own `from`. |
| enrollment.status req | "active" · "waiting" · "completed" · "canceled" · "failed" | |
| enrollment.stepIndex req | number | The next step to run, indexed into the compiled program. |
| enrollment.nextAt | number · null | When the next step is due, in ms epoch. Null while parked on an event or terminal. |
| enrollment.waitEvent | string · null | The event name this enrollment is parked on. |
| enrollment.waitUntil | number · null | When the parked wait times out, in ms epoch. |
| enrollment.input | object · null | The validated input this contact was enrolled with. Steps read it as {{input.*}} — and {{event.*}}, the original alias. |
| enrollment.trigger | object · null | How the enrollment started: { type: 'event'|'send'|'manual', event?, messageId?, from?, to?, subject?, templateId? }. Steps read it as {{trigger.*}}. `input` is what you passed; this is how it started. |
| enrollment.cancelKey | string · null | Your own key for this enrollment — cancel by it without needing our id. |
| enrollment.error | string · null | Why a failed enrollment stopped. |
| enrollment.createdAt req | number | |
| enrollment.updatedAt | number |
An actual response, recorded from the conformance suite.
{
"enrollment": {
"id": "enr_5b2d9f01",
"sequenceId": "seq_3c8f21aa",
"sequenceVersion": 1,
"email": "ada@example.com",
"contactId": "ctc_8Rt5NmZx",
"from": "billing@acme.dev",
"status": "active",
"stepIndex": 0,
"nextAt": 1799999999000,
"waitEvent": null,
"waitUntil": null,
"input": {
"invoiceId": "inv_1042"
},
"trigger": {
"type": "manual"
},
"cancelKey": "invoice_9f2c",
"error": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/enrollment-response.json",
"title": "Enrollment response body",
"type": "object",
"required": [
"enrollment"
],
"properties": {
"enrollment": {
"$ref": "enrollment.json"
}
}
} GET /v1/sequences/:id/enrollments List who is in a sequence and where each of them is. Filter with `status`.
List who is in a sequence and where each of them is. Filter with `status`.
Response body
list-enrollments-response.json| Field | Type | Notes |
|---|---|---|
| enrollments req | enrollment[] | Enrollments on this sequence, newest first. |
| enrollments[].id req | string | Enrollment id (enr_…). |
| enrollments[].sequenceId req | string | |
| enrollments[].sequenceVersion | number | The sequence version this contact is walking, pinned at enrollment. |
| enrollments[].email req | string | The enrolled address, normalized to lowercase. |
| enrollments[].contactId | string · null | The contact this resolved to, when the address is one you hold. |
| enrollments[].from | string | The sender frozen at enrollment — send steps use it unless they override with their own `from`. |
| enrollments[].status req | "active" · "waiting" · "completed" · "canceled" · "failed" | |
| enrollments[].stepIndex req | number | The next step to run, indexed into the compiled program. |
| enrollments[].nextAt | number · null | When the next step is due, in ms epoch. Null while parked on an event or terminal. |
| enrollments[].waitEvent | string · null | The event name this enrollment is parked on. |
| enrollments[].waitUntil | number · null | When the parked wait times out, in ms epoch. |
| enrollments[].input | object · null | The validated input this contact was enrolled with. Steps read it as {{input.*}} — and {{event.*}}, the original alias. |
| enrollments[].trigger | object · null | How the enrollment started: { type: 'event'|'send'|'manual', event?, messageId?, from?, to?, subject?, templateId? }. Steps read it as {{trigger.*}}. `input` is what you passed; this is how it started. |
| enrollments[].cancelKey | string · null | Your own key for this enrollment — cancel by it without needing our id. |
| enrollments[].error | string · null | Why a failed enrollment stopped. |
| enrollments[].createdAt req | number | |
| enrollments[].updatedAt | number |
An actual response, recorded from the conformance suite.
{
"enrollments": [
{
"id": "enr_5b2d9f01",
"sequenceId": "seq_3c8f21aa",
"sequenceVersion": 1,
"email": "ada@example.com",
"contactId": "ctc_8Rt5NmZx",
"from": "billing@acme.dev",
"status": "active",
"stepIndex": 0,
"nextAt": 1799999999000,
"waitEvent": null,
"waitUntil": null,
"input": {
"invoiceId": "inv_1042"
},
"trigger": {
"type": "manual"
},
"cancelKey": "invoice_9f2c",
"error": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/list-enrollments-response.json",
"title": "List enrollments response body",
"type": "object",
"required": [
"enrollments"
],
"properties": {
"enrollments": {
"type": "array",
"description": "Enrollments on this sequence, newest first.",
"items": {
"$ref": "enrollment.json"
}
}
}
} GET /v1/enrollments/:id Get one enrollment — which sequence, which step, and what happens next.
Get one enrollment — which sequence, which step, and what happens next.
Response body
enrollment-response.json| Field | Type | Notes |
|---|---|---|
| enrollment req | enrollment | One contact walking one sequence — where they are, and what happens next. `active` means a step is due at `nextAt`; `waiting` means parked on an event; `completed`, `canceled`, and `failed` are terminal. |
| enrollment.id req | string | Enrollment id (enr_…). |
| enrollment.sequenceId req | string | |
| enrollment.sequenceVersion | number | The sequence version this contact is walking, pinned at enrollment. |
| enrollment.email req | string | The enrolled address, normalized to lowercase. |
| enrollment.contactId | string · null | The contact this resolved to, when the address is one you hold. |
| enrollment.from | string | The sender frozen at enrollment — send steps use it unless they override with their own `from`. |
| enrollment.status req | "active" · "waiting" · "completed" · "canceled" · "failed" | |
| enrollment.stepIndex req | number | The next step to run, indexed into the compiled program. |
| enrollment.nextAt | number · null | When the next step is due, in ms epoch. Null while parked on an event or terminal. |
| enrollment.waitEvent | string · null | The event name this enrollment is parked on. |
| enrollment.waitUntil | number · null | When the parked wait times out, in ms epoch. |
| enrollment.input | object · null | The validated input this contact was enrolled with. Steps read it as {{input.*}} — and {{event.*}}, the original alias. |
| enrollment.trigger | object · null | How the enrollment started: { type: 'event'|'send'|'manual', event?, messageId?, from?, to?, subject?, templateId? }. Steps read it as {{trigger.*}}. `input` is what you passed; this is how it started. |
| enrollment.cancelKey | string · null | Your own key for this enrollment — cancel by it without needing our id. |
| enrollment.error | string · null | Why a failed enrollment stopped. |
| enrollment.createdAt req | number | |
| enrollment.updatedAt | number |
An actual response, recorded from the conformance suite.
{
"enrollment": {
"id": "enr_5b2d9f01",
"sequenceId": "seq_3c8f21aa",
"sequenceVersion": 1,
"email": "ada@example.com",
"contactId": "ctc_8Rt5NmZx",
"from": "billing@acme.dev",
"status": "active",
"stepIndex": 0,
"nextAt": 1799999999000,
"waitEvent": null,
"waitUntil": null,
"input": {
"invoiceId": "inv_1042"
},
"trigger": {
"type": "manual"
},
"cancelKey": "invoice_9f2c",
"error": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
}
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/enrollment-response.json",
"title": "Enrollment response body",
"type": "object",
"required": [
"enrollment"
],
"properties": {
"enrollment": {
"$ref": "enrollment.json"
}
}
} GET /v1/enrollments/:id/runs Every step this enrollment has executed, with the outcome and the reason for it. This is the "why didn't step 3 fire" view.
Every step this enrollment has executed, with the outcome and the reason for it. This is the "why didn't step 3 fire" view.
Response body
list-runs-response.json| Field | Type | Notes |
|---|---|---|
| enrollment req | enrollment | One contact walking one sequence — where they are, and what happens next. `active` means a step is due at `nextAt`; `waiting` means parked on an event; `completed`, `canceled`, and `failed` are terminal. |
| enrollment.id req | string | Enrollment id (enr_…). |
| enrollment.sequenceId req | string | |
| enrollment.sequenceVersion | number | The sequence version this contact is walking, pinned at enrollment. |
| enrollment.email req | string | The enrolled address, normalized to lowercase. |
| enrollment.contactId | string · null | The contact this resolved to, when the address is one you hold. |
| enrollment.from | string | The sender frozen at enrollment — send steps use it unless they override with their own `from`. |
| enrollment.status req | "active" · "waiting" · "completed" · "canceled" · "failed" | |
| enrollment.stepIndex req | number | The next step to run, indexed into the compiled program. |
| enrollment.nextAt | number · null | When the next step is due, in ms epoch. Null while parked on an event or terminal. |
| enrollment.waitEvent | string · null | The event name this enrollment is parked on. |
| enrollment.waitUntil | number · null | When the parked wait times out, in ms epoch. |
| enrollment.input | object · null | The validated input this contact was enrolled with. Steps read it as {{input.*}} — and {{event.*}}, the original alias. |
| enrollment.trigger | object · null | How the enrollment started: { type: 'event'|'send'|'manual', event?, messageId?, from?, to?, subject?, templateId? }. Steps read it as {{trigger.*}}. `input` is what you passed; this is how it started. |
| enrollment.cancelKey | string · null | Your own key for this enrollment — cancel by it without needing our id. |
| enrollment.error | string · null | Why a failed enrollment stopped. |
| enrollment.createdAt req | number | |
| enrollment.updatedAt | number | |
| runs req | step-run[] | Executed steps in order. |
| runs[].id req | string | |
| runs[].stepIndex req | number | Which step in the compiled program this was. |
| runs[].stepType req | string | delay | send | exit | condition | wait_for_event | branch_ai. |
| runs[].outcome req | "sent" · "skipped" · "branched" · "waited" · "failed" · "scheduled" · "completed" | |
| runs[].detail | string · null | Human-readable reason for the outcome. |
| runs[].messageId | string · null | The message a `send` step produced (msg_…). |
| runs[].ranAt req | number | ms epoch. |
An actual response, recorded from the conformance suite.
{
"enrollment": {
"id": "enr_5b2d9f01",
"sequenceId": "seq_3c8f21aa",
"sequenceVersion": 1,
"email": "ada@example.com",
"contactId": "ctc_8Rt5NmZx",
"from": "billing@acme.dev",
"status": "active",
"stepIndex": 0,
"nextAt": 1799999999000,
"waitEvent": null,
"waitUntil": null,
"input": {
"invoiceId": "inv_1042"
},
"trigger": {
"type": "manual"
},
"cancelKey": "invoice_9f2c",
"error": null,
"createdAt": 1799999999000,
"updatedAt": 1799999999000
},
"runs": [
{
"id": "run_a71f0c22",
"stepIndex": 0,
"stepType": "delay",
"outcome": "skipped",
"detail": "delayed 3 days",
"messageId": null,
"ranAt": 1799999999000
}
]
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/list-runs-response.json",
"title": "Enrollment runs response body",
"type": "object",
"required": [
"enrollment",
"runs"
],
"properties": {
"enrollment": {
"$ref": "enrollment.json"
},
"runs": {
"type": "array",
"description": "Executed steps in order.",
"items": {
"$ref": "step-run.json"
}
}
}
} DELETE /v1/enrollments/:id Stop a contact partway through a sequence. Answers 409 when the enrollment has already finished or been canceled.
Stop a contact partway through a sequence. Answers 409 when the enrollment has already finished or been canceled.
Response body
cancel-enrollment-response.json| Field | Type | Notes |
|---|---|---|
| id req | string | |
| status req | string |
An actual response, recorded from the conformance suite.
{
"id": "enr_5b2d9f01",
"status": "canceled"
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/cancel-enrollment-response.json",
"title": "Cancel enrollment response body",
"type": "object",
"required": [
"id",
"status"
],
"properties": {
"id": {
"type": "string",
"examples": [
"enr_5b2d9f01"
]
},
"status": {
"type": "string",
"examples": [
"canceled"
]
}
}
} POST /v1/enrollments/cancel Stop every in-flight enrollment carrying a `cancelKey` you set when enrolling — so you can cancel with your own invoice or order id instead of storing ours. Always answers 200 with a count, so it is safe to fire blindly from a webhook.
Stop every in-flight enrollment carrying a `cancelKey` you set when enrolling — so you can cancel with your own invoice or order id instead of storing ours. Always answers 200 with a count, so it is safe to fire blindly from a webhook.
Request body
cancel-by-key-request.jsonStop every in-flight enrollment carrying this key. The point is that you never have to store our enrollment id — you already have the invoice number, order id, or subscription id the sequence is about.
| Field | Type | Notes |
|---|---|---|
| cancelKey req | string | The key you passed when enrolling. |
The smallest body that makes this call — every other field is optional.
{
"cancelKey": "invoice_9f2c"
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/cancel-by-key-request.json",
"title": "Cancel enrollments by key request body",
"description": "Stop every in-flight enrollment carrying this key. The point is that you never have to store our enrollment id — you already have the invoice number, order id, or subscription id the sequence is about.",
"type": "object",
"required": [
"cancelKey"
],
"additionalProperties": false,
"properties": {
"cancelKey": {
"type": "string",
"description": "The key you passed when enrolling.",
"examples": [
"invoice_9f2c"
]
}
}
} Response body
cancel-by-key-response.jsonAlways 200 with a count, never 404 — cancelling something already finished is a no-op you can fire blindly from a webhook without branching on our state.
| Field | Type | Notes |
|---|---|---|
| cancelKey req | string | |
| canceled req | number | How many in-flight enrollments were stopped. Zero is a normal answer. |
An actual response, recorded from the conformance suite.
{
"cancelKey": "invoice_9f2c",
"canceled": 1
} {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.mailkite.dev/v1/schemas/cancel-by-key-response.json",
"title": "Cancel enrollments by key response body",
"description": "Always 200 with a count, never 404 — cancelling something already finished is a no-op you can fire blindly from a webhook without branching on our state.",
"type": "object",
"required": [
"cancelKey",
"canceled"
],
"properties": {
"cancelKey": {
"type": "string"
},
"canceled": {
"type": "number",
"description": "How many in-flight enrollments were stopped. Zero is a normal answer."
}
}
}