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.
Starting one: direct, or by event
There are two ways to start a sequence, and choosing between them is really one question: does your code know which sequence it wants, or only what happened?
startSequence() | sendEvent() | |
|---|---|---|
| Your code knows | which sequence | what happened |
| It is coupled to | one named automation | a fact about your product |
| Adding a second sequence | a code change and a deploy | nothing — attach a trigger |
| Reaches | exactly that sequence | every sequence listening, and wakes anyone parked on it |
| Reach for it when | an operator clicks "chase this invoice"; a script backfills; you are testing | your billing code notices a failed payment and should not have to know what happens next |
Neither is the "real" one. startSequence is a direct call;
sendEvent reports a fact and lets policy decide what reacts — which is why your
billing code does not have to learn that a second dunning sequence now exists.
There is also a third, which is really the first in disguise: naming a sequence on a send you are already making. That needs no trigger — naming it is the consent.
Start one directly
Sequences → open it → Enrollments → EnrollStart the dunning sequence for ada@example.com, invoice inv_1042.import { MailKite } from "mailkite";
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const { enrollment } = await mk.startSequence("dunning", {
email: "ada@example.com",
input: { invoiceId: "inv_1042", amountDue: 4200 },
// Your own id, so you never have to store ours to cancel later.
cancelKey: "inv_1042",
});import os
from mailkite import MailKite
mk = MailKite(os.environ["MAILKITE_API_KEY"])
res = mk.startSequence("dunning", {
"email": "ada@example.com",
"input": {"invoiceId": "inv_1042", "amountDue": 4200},
"cancelKey": "inv_1042",
})curl -X POST https://api.mailkite.dev/v1/sequences/dunning/start \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"input": { "invoiceId": "inv_1042", "amountDue": 4200 },
"cancelKey": "inv_1042"
}'
Takes the sequence name or id, so this needs no lookup. It returns the
enrollment it created — the run you then inspect, follow, or cancel. Set a
cancelKey you already have (an invoice id, an order id) and you never need to store
ours to stop it later.
A suppressed address, a contact already running on a reentry: "once" sequence, or a
missing required input is refused with a 409 that says which.
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.
Start one by event
Post the fact; whatever is listening reacts. Your billing code says "payment failed" and stays out of the business of knowing which follow-ups exist.
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 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
stopSequence() is the direct cancel — by the cancelKey you started
with, or by sequence and email. It always answers with a count, so it is safe to fire blindly
from a webhook without checking our state first.
But the better answer is usually to let the sequence stop itself.
wait_for_event is the step the whole feature exists for: chase the unpaid
invoice, unless they pay. Post invoice.paid and every sequence waiting on it
moves on — including the ones you had forgotten about.
{
"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.// Directly, with the key you started it with.
await mk.stopSequence({ cancelKey: "inv_1042" });
// …or, if you never set one:
await mk.stopSequence({ sequence: "dunning", email: "ada@example.com" });
// Or indirectly: post the event a wait_for_event step is parked on, and every
// sequence waiting for it moves on — including ones you'd forgotten about.
await mk.sendEvent({ name: "invoice.paid", email: "ada@example.com" });mk.stopSequence({"cancelKey": "inv_1042"})
mk.stopSequence({"sequence": "dunning", "email": "ada@example.com"})
mk.sendEvent({"name": "invoice.paid", "email": "ada@example.com"})curl -X POST https://api.mailkite.dev/v1/sequences/stop \
-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.