Get your API key
Sending email

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

start a sequence
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",
});
Install Docs →

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.

the signature
{
"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

merge namespaces
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

create a sequence
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>",
},
],
});
Install Docs →

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.

post an event
// 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",
});
Install Docs →
response
← 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.

trigger on send
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
});
Install Docs →

Only some occurrences

A trigger can carry a filter, so the door only opens when the event is worth acting on.

chase, but only when it's worth chasing
{
"event": "payment.failed",
"filter": { "field": "event.amountDue", "op": "gt", "value": 5000 }
}

Steps

StepWhat it does
delayWait a duration — "3 days", "90 minutes". Up to 30 days per step.
sendSend one email. Content resolves when the step fires, so merge tags see the contact as they are then.
conditionBranch on the input or the contact. then and else are ordinary step lists, so branches nest.
wait_for_eventPark until an event arrives. then runs when it does, else when the wait times out.
branch_aiAsk a model which branch to take, from a fixed set of choices. It can only choose — never write, never send.
exitStop 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.

dunning, in full
{
"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.

stop a sequence
// 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" });
Install Docs →

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.