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.

The three ways a contact is enrolled

Only the first involves a trigger. All three are explicit.

Way inHowNeeds 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.

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.

Trigger it from your code

This is the interface. Post the fact; whatever is listening reacts.

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 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.

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

wait_for_event is the step the whole feature exists for: chase the unpaid invoice, unless they pay.

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
// 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" });
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.