Sequences
A sequence is a function you call with an event. Your application already knows when a payment failed or a trial is expiring — it says so, and a sequence of timed, conditional emails runs from there. It stops on its own the moment the customer does the thing you were chasing them about.
What a trigger is
A trigger is one named event that starts a sequence. That is the whole
definition — there is exactly one kind, and it is an event your own code posts to
POST /v1/events. Nothing is inferred from your mail traffic: a sequence starts
because you said so.
Triggers are a separate resource from the sequence itself. One sequence can have
several — dunning that should start on payment.failed,
invoice.overdue, or subscription.past_due is one sequence with
three triggers, not three copies. And because they are separate, you can attach, detach, or
disable one while contacts are mid-sequence: it changes who starts in future
and touches nobody already walking it.
The three ways a contact is enrolled
Only the first involves a trigger. All three are explicit.
| Way in | How | Needs a trigger? |
|---|---|---|
| An event the primary path | POST /v1/events with a name one of your triggers matches | Yes |
| Naming it on a send | POST /v1/send with sequence: "dunning" | No — naming it is the consent |
| Enrolling directly | POST /v1/sequences/{id}/enroll | No |
A sequence with no triggers is not broken — it simply runs when you name it on a send or enrol someone yourself.
The input shape
A sequence declares the shape it is called with, the way a function declares its parameters.
Every way in must satisfy it, so {{input.invoiceId}} in step
three means the same thing however the sequence was started.
{
"input": {
"invoiceId": { "type": "string", "required": true },
"amountDue": { "type": "number" },
"plan": { "type": "string", "default": "free" }
}
}
Fields are string, number, or boolean — exactly what a
merge tag or a condition can read. A missing required field refuses the enrollment and
tells you which one, rather than surfacing days later as a blank line in a real email.
Values are coerced where that is obviously right ("4200" for a
number), because that is what JSON payloads and form posts actually carry.
Declaring an input is optional. Leave it out and anything scalar you pass is kept — you should not need a schema to write a two-step drip.
What a step can read
Subject: Invoice {{input.invoiceId}} for {{contact.name}}
{{input.*}} what you passed — the fields your signature declares
{{trigger.*}} how it started — type, event, messageId, from, subject
{{contact.*}} the contact's own fields and stored properties
{{event.*}} an alias for {{input.*}}, kept for compatibility input is what you passed; trigger is how it started. Keeping them apart
is what lets a step be written without knowing which way in fired it.
Build one
Sequences → New sequence
Name dunning
Triggers payment.failed
From billing@myapp.ai
Steps Wait 3 days → Send "Your invoice is still unpaid"
Click [ Create & activate ]Create a sequence called "dunning" that starts on a payment.failed event,
waits 3 days, then emails a reminder with the invoice id.import { MailKite } from "mailkite";
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const seq = await mk.createSequence({
name: "dunning",
status: "active",
from: "billing@myapp.ai",
// The signature. Every way in must supply this shape.
input: {
invoiceId: { type: "string", required: true },
amountDue: { type: "number" },
},
// The doors. Each is an event name your code posts.
triggers: ["payment.failed"],
steps: [
{ type: "delay", for: "3 days" },
{
type: "send",
subject: "Invoice {{input.invoiceId}} is still unpaid",
html: "<p>We couldn't take payment for {{input.invoiceId}}.</p>",
},
],
});import os
from mailkite import MailKite
mk = MailKite(os.environ["MAILKITE_API_KEY"])
seq = mk.createSequence({
"name": "dunning",
"status": "active",
"from": "billing@myapp.ai",
"input": {
"invoiceId": {"type": "string", "required": True},
"amountDue": {"type": "number"},
},
"triggers": ["payment.failed"],
"steps": [
{"type": "delay", "for": "3 days"},
{"type": "send", "subject": "Invoice {{input.invoiceId}} is still unpaid",
"html": "<p>We couldn't take payment for {{input.invoiceId}}.</p>"},
],
})curl -X POST https://api.mailkite.dev/v1/sequences \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "dunning",
"status": "active",
"from": "billing@myapp.ai",
"input": { "invoiceId": { "type": "string", "required": true } },
"triggers": ["payment.failed"],
"steps": [
{ "type": "delay", "for": "3 days" },
{ "type": "send", "subject": "Invoice {{input.invoiceId}} is still unpaid", "text": "…" }
]
}'
Sequences are created as a draft unless you pass status: "active"
— a draft enrols nobody. The whole definition is validated up front and
every problem is reported at once, so you fix a program in one pass.
A sequence needs a from address on a verified domain once it has a trigger: an
event has no message to inherit a sender from. One named on a send always has one.
Trigger it from your code
This is the interface. Post the fact; whatever is listening reacts.
Sequences are triggered by your application, not from the dashboard.
To start one by hand: Sequences → open it → Enrollments → EnrollRecord that ada@example.com's payment failed for invoice inv_1042.// Your app already knows this happened. Say so.
await mk.sendEvent({
name: "payment.failed",
email: "ada@example.com",
payload: { invoiceId: "inv_1042", amountDue: 4200 },
// A retried webhook stays one event — and therefore one enrollment.
dedupeKey: "inv_1042-attempt-3",
});mk.sendEvent({
"name": "payment.failed",
"email": "ada@example.com",
"payload": {"invoiceId": "inv_1042", "amountDue": 4200},
"dedupeKey": "inv_1042-attempt-3",
})curl -X POST https://api.mailkite.dev/v1/events \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "payment.failed",
"email": "ada@example.com",
"payload": { "invoiceId": "inv_1042", "amountDue": 4200 },
"dedupeKey": "inv_1042-attempt-3"
}' ← 202 Accepted
{
"id": "cev_4b1c9e77",
"object": "event",
"name": "payment.failed",
"email": "ada@example.com",
"duplicate": false
} 202 because the event is recorded synchronously but everything it sets in motion is
not. Pass a dedupeKey and a retried webhook stays one event — and therefore one
enrollment, not two overlapping email streams at the same person.
Or start it from a send you already make
One field on a call you are already making. The sequence inherits that message's sender, and its
input is auto-injected from templateData and metadata.
The dashboard composer sends immediately.
Name a sequence from the API, or enrol from Sequences → Enrollments.Send the invoice email to ada@example.com and start the dunning sequence for her.await mk.send({
from: "billing@myapp.ai",
to: "ada@example.com",
subject: "Invoice #1042",
html: "<p>Your invoice is attached.</p>",
sequence: "dunning", // ← the whole integration
sequenceInput: { invoiceId: "inv_1042" }, // explicit params beat auto-injected ones
});mk.send({
"from": "billing@myapp.ai",
"to": "ada@example.com",
"subject": "Invoice #1042",
"html": "<p>Your invoice is attached.</p>",
"sequence": "dunning",
"sequenceInput": {"invoiceId": "inv_1042"},
})curl -X POST https://api.mailkite.dev/v1/send \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "billing@myapp.ai",
"to": "ada@example.com",
"subject": "Invoice #1042",
"html": "<p>Your invoice is attached.</p>",
"sequence": "dunning",
"sequenceInput": { "invoiceId": "inv_1042" }
}' Only some occurrences
A trigger can carry a filter, so the door only opens when the event is worth acting on.
{
"event": "payment.failed",
"filter": { "field": "event.amountDue", "op": "gt", "value": 5000 }
} Steps
| Step | What it does |
|---|---|
delay | Wait a duration — "3 days", "90 minutes". Up to 30 days per step. |
send | Send one email. Content resolves when the step fires, so merge tags see the contact as they are then. |
condition | Branch on the input or the contact. then and else are ordinary step lists, so branches nest. |
wait_for_event | Park until an event arrives. then runs when it does, else when the wait times out. |
branch_ai | Ask a model which branch to take, from a fixed set of choices. It can only choose — never write, never send. |
exit | Stop here. |
Stopping the chase
wait_for_event is the step the whole feature exists for: chase the unpaid
invoice, unless they pay.
{
"name": "dunning",
"from": "billing@myapp.ai",
"input": { "invoiceId": { "type": "string", "required": true } },
"triggers": ["payment.failed", "invoice.overdue"],
"steps": [
{ "type": "send", "subject": "We couldn't take payment for {{input.invoiceId}}", "text": "…" },
{
"type": "wait_for_event",
"event": "invoice.paid",
"timeout": "3 days",
"then": [{ "type": "exit" }],
"else": [{ "type": "send", "subject": "Final notice", "text": "…" }]
}
]
} Timing out continues the sequence rather than cancelling it — the follow-up is the default and the event is the escape, so an event you forget to send can never silently kill a campaign.
Sequences → open it → Enrollments → CancelStop the dunning follow-ups for invoice inv_1042 — it's been paid.// Either: post the event the sequence is waiting for…
await mk.sendEvent({ name: "invoice.paid", email: "ada@example.com" });
// …or cancel with your OWN key, so you never store our enrollment id.
await mk.cancelEnrollmentsByKey({ cancelKey: "inv_1042" });mk.sendEvent({"name": "invoice.paid", "email": "ada@example.com"})
mk.cancelEnrollmentsByKey({"cancelKey": "inv_1042"})curl -X POST https://api.mailkite.dev/v1/enrollments/cancel \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "cancelKey": "inv_1042" }' Editing a live sequence
Contacts already mid-sequence keep walking the version they started on. Insert a step at the top and nobody in flight repeats an email or skips one; new enrollments get the new program. Only a change to the steps creates a version — a rename, a pause, the input signature, and the trigger set are all editable while people are mid-flight.
Pause leaves everyone parked, so unpausing resumes rather than restarts. Archiving or deleting retires them.
Why didn't it fire?
Every executed step is recorded with its outcome and the reason for it — which branch was taken, which condition failed, why a send was skipped, what a model decided and why. Open a sequence in the dashboard and drill into an enrollment, or read it over the API.
If nothing started at all, check GET /v1/events: the event has to have arrived
before anything can react to it.