Build programmatic email workflows with JavaScript
Reviewed
Programmatic email starts with an application fact, such as a failed payment, and turns it into a message or timed workflow. This JavaScript guide builds a payment-reminder sequence with MailKite (which we build), records deduplicated events, and stops reminders when payment completes. The runnable mock proves the event and cancellation paths without sending anything.
The event in the complete Node example is:
const failedEvent = {
name: 'payment.failed', email,
payload: { invoiceId: 'guide-invoice-1042' },
dedupeKey: 'guide:invoice-1042:payment-attempt-1',
};
The worker calls the SDK’s mk.sendEvent(failedEvent) through its checkpoint function. email is the fixed controlled test recipient. The dedupeKey identifies one payment attempt; submitting that same fact again returns the original event with duplicate: true. It doesn’t imply that every future payment failure for this invoice should reuse the same key.
Run the event-to-exit demonstration
Download programmatic.mjs into a private working directory. With Node 18 or newer:
node programmatic.mjs
node programmatic.mjs
The default MOCK demo creates a local active sequence definition, records payment.failed, repeats it to check deduplication, then records invoice.paid. One enrollment is created and moves from waiting to completed. A final cancellation call returns stopped: 0, which is a valid no-op because completion already ended it. The second invocation reuses saved operation results and still has one enrollment.
Inspect programmatic-mock.json. It contains two event acknowledgements, one completed mock enrollment, and checkpointed operations. The mock models deduplication, one matching event wait, and cancellation. It doesn’t run a three-day timer, render a reminder, or reproduce the full provider engine. The test file also verifies cancellation of a still-waiting enrollment, with stopped: 1.
Keep business facts separate from email policy
Your application knows whether an invoice exists, which account owns it, and whether it was paid. The email worker shouldn’t decide payment state from an open or click. Persist the business change and an outbox event in the same database transaction, then let a server worker post the event after commit.
The outbox record needs a stable event identity, recipient, payload, timestamp, and dispatch state. A replay uses the same dedupeKey and payload. A new payment attempt gets a new key. Prefix keys with your tenant and business object identity in a multi-tenant system so unrelated events don’t collide.
sendEvent identifies a subject with email or contactId, never both. An event is an observation, not a subscription; posting an unknown address isn’t a substitute for subscriber acquisition. Check current recipient preferences in your application before dispatch. Payment reminders and marketing newsletters can have different purposes even though both are triggered programmatically.
Define a sequence with a real exit
The executable declares one required input and a timed event wait. Its step list is:
steps: [
{
type: 'wait_for_event', event: 'invoice.paid', timeout: '3 days',
filter: { field: 'event.invoiceId', op: 'eq', value: 'guide-invoice-1042' },
then: [{ type: 'exit' }],
else: [{ type: 'send', subject: 'Invoice {{input.invoiceId}} needs attention', text: 'Please review invoice {{input.invoiceId}} in your account.' }],
},
],
This is an excerpt from the definition passed to createSequence. The full object also supplies name, from, status, reentry: 'once', triggers: ['payment.failed'], and input: { invoiceId: { type: 'string', required: true } }. Required inputs are checked before enrollment. {{input.invoiceId}} refers to the passed input, while trigger describes how the workflow started.
The wait’s then branch exits when payment arrives. Its else branch sends a reminder on timeout. A delay by itself cannot cancel a reminder when a customer pays. The sequence documentation also describes events arriving before the wait is reached: events since enrollment can still satisfy the wait.
The filter value is deliberately fixed to this one sample invoice. It is not a documented dynamic comparison between two payload fields. Don’t copy this definition into a multi-invoice billing workflow unchanged. If your application must cancel one particular invoice among several, direct enrollment with a business cancelKey can give you the correlation you need; an event-created enrollment doesn’t automatically acquire that key.
Install the SDK and create a draft
The npm package checked October 14, 2026 is 0.20.0:
npm install mailkite@0.20.0
The live branch loads the official server SDK:
const { MailKite } = await import('mailkite');
mk = new MailKite(process.env.MAILKITE_API_KEY);
Set MAILKITE_API_KEY and MAILKITE_FROM through your server environment. Use a verified account and sender domain; see authentication and domains. Keep the key in the server process, never a client-side bundle.
Then run node programmatic.mjs --live --action draft. This creates guide-payment-recovery as a draft, which enrolls nobody, and stores the response in programmatic-live.json. Review the definition and explicitly activate it in the dashboard only if you intend to test its reminder behavior. The live command doesn’t activate it for you.
node programmatic.mjs --live --action failed posts the failure event for only test-programmatic-guide@mailkite.dev. An active sequence listening for payment.failed can react, including other pre-existing sequences on the account. The controlled address is a project test recipient, not an inbox you own or can inspect. Default mock mode is the right way to run the full tutorial without these side effects.
Complete or cancel the workflow
The completion event in the executable is:
const paidEvent = {
name: 'invoice.paid', email,
payload: { invoiceId: 'guide-invoice-1042' },
dedupeKey: 'guide:invoice-1042:paid',
};
--live --action paid posts it using sendEvent. Event acknowledgement means the fact was recorded; downstream sequence work happens asynchronously. Inspect enrollment state and step runs rather than assuming the HTTP response proves the reminder was canceled instantly.
--live --action cancel calls stopSequence({ sequence: name, email }). This stops in-flight runs for the named sequence and address, with a stopped count. It doesn’t recall already sent mail and doesn’t cancel unrelated sequences. Zero is normal for a completed or already-stopped run.
Direct enrollment is a separate entry path: startSequence(sequence, { email, input, cancelKey }). A later stopSequence({ cancelKey }) uses the key actually stored when starting. Choose event entry when your code knows what happened and triggers decide who reacts; choose direct entry when your code knows exactly which workflow and business object to start. Don’t call both for the same fact and assume deduplication bridges them.
Recover without inventing idempotency
Before each operation, the download saves inflight intent. Finished operations keep their response and aren’t repeated. Exceptions are stored as unknown, without dumping raw credential-bearing error objects.
Event posting may be retried with the same documented dedupeKey; the checkpoint marks that operation replay-safe. Stopping is also repeatable because the endpoint returns a count for remaining work. Sequence creation isn’t assumed idempotent: if its response is lost, inspect listSequences and reconcile the saved operation before retrying. The demo blocks an ambiguous creation.
These checkpoints don’t form an atomic transaction with MailKite. The JSON rename reduces torn local writes, but there is no distributed locking or exactly-once promise. For production, use a transactional outbox, worker leases, deadlines, capped backoff, and observable dispatch attempts. Preserve keys on retries, and recheck business state before a reminder is eligible.
Use the smallest orchestration that fits
| Need | Suitable mechanism | Recovery boundary |
|---|---|---|
| One notification now | send | Persist per-message result; hold ambiguous sends |
| One notification later | scheduledAt | Keep ssnd_… ID for inspection/cancellation |
| Timed follow-ups with completion | Sequence and application events | Input, exits, dedupe and enrollment state |
| Cross-service business workflow | Your queue/workflow system plus SDK | Transactional business state and worker ownership |
Inspect listEvents when nothing enrolled, then listEnrollments, getEnrollment, and listEnrollmentRuns for step outcomes. Verify signed inbound or engagement webhooks before trusting them; see webhook security. A reply routed to your endpoint can become a verified application stop condition, but that mapping belongs to your app.
Continue with the programmatic email hub, bulk architecture, JavaScript batch worker, or bulk API comparison. The planned JavaScript newsletter and cold-email guides cover different audience lifecycles.
Sources checked October 14, 2026: sequences, event schema, sequence definition schema, stop response schema, and Node SDK source. Keep the download README for commands and limits, and bring integration questions to community discussions.