MailcrateDocs

Webhooks

Receive signed HTTP callbacks when deliveries bounce, contacts unsubscribe or journeys finish, and verify them.

A webhook endpoint is a URL in your system that Mailcrate POSTs to whenever something happens in your workspace: a delivery bounces, a contact unsubscribes, a journey run ends. Each endpoint subscribes to the events it wants, and every request is signed so you can prove it came from Mailcrate.

Set up an endpoint

Create it

In the app, under Settings → Workspace → Webhooks, or over the API with a key that has webhook:write:

POST/public/webhook/endpointsscope · webhook:write
Terminal
curl -X POST https://api.mailcrate.io/public/webhook/endpoints \
  -H "Authorization: Api-Key $MAILCRATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order service",
    "url": "https://example.com/webhooks/mailcrate",
    "subscribedEvents": ["delivery.bounced", "delivery.complained", "contact.unsubscribed"]
  }'

The response carries the endpoint's signing secret (whsec_...). Store it with your other secrets: later reads show it masked. If you lose it, rotate it with POST /public/webhook/endpoints/{id}/regenerate-secret.

Verify and acknowledge each request

Check the signature (below), then answer with any 2xx status within 30 seconds. Do slow work after you respond, from a queue of your own.

Watch the deliveries

Every delivery and every attempt is recorded. GET /public/webhook/deliveries/{id}/attempts shows the status your endpoint answered with, or the network error when it answered nothing, which is the first place to look when events seem to go missing.

Local development

Mailcrate refuses endpoint URLs that resolve to private or local addresses, so http://localhost:3000 will not receive anything. Expose your local server through a tunnel (ngrok, Cloudflare Tunnel, or similar) and register the tunnel's public URL.

What a request looks like

POST /webhooks/mailcrate HTTP/1.1
Content-Type: application/json
User-Agent: Mailcrate-Webhook/1.0
X-Webhook-Id: 0f6c5f8a-6e2f-4a56-9b0e-6d3f3d2a1c77
X-Webhook-Timestamp: 2026-08-03T11:04:21.530Z
X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c592ae2d3c5b0e8f1d2a9c4b7e6f3a2d1c0b

Every delivery has the same envelope; only data varies by event:

{
  "id": "0f6c5f8a-6e2f-4a56-9b0e-6d3f3d2a1c77",
  "event": "journey.exited",
  "timestamp": "2026-08-03T11:04:21.512Z",
  "workspaceId": "81c55e4e-cabb-4824-b0db-869d25a863a7",
  "data": { }
}

id identifies the delivery, not the subject. It is also sent as the X-Webhook-Id header, and it stays the same across retries, so it is the key to make your handler idempotent.

Verify the signature

X-Webhook-Signature is a hex-encoded HMAC-SHA256 of the timestamp header, a dot, and the raw request body, keyed with the endpoint's secret:

hex( HMAC_SHA256( secret, X-Webhook-Timestamp + "." + raw_body ) )

Compute it over the bytes you received, before any JSON parsing: re-serialising the parsed body can reorder or re-space it and the signature will not match. Compare in constant time, and reject requests whose timestamp is more than a few minutes old so a captured request cannot be replayed later.

webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.MAILCRATE_WEBHOOK_SECRET!; // whsec_...

app.post('/webhooks/mailcrate', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.header('x-webhook-timestamp') ?? '';
  const signature = req.header('x-webhook-signature') ?? '';

  const expected = createHmac('sha256', secret).update(`${timestamp}.${req.body}`).digest();
  const received = Buffer.from(signature, 'hex');
  const valid = received.length === expected.length && timingSafeEqual(received, expected);
  const fresh = Math.abs(Date.now() - Date.parse(timestamp)) < 5 * 60 * 1000;

  if (!valid || !fresh) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Queue the work, keyed on event.id, then acknowledge.
  console.log(event.event, event.id);
  res.sendStatus(200);
});

app.listen(3000);

The secret is used exactly as shown, whsec_ prefix included. The timestamp is an ISO 8601 string, not a Unix epoch.

Retries

A delivery counts as failed when your endpoint answers anything other than 2xx (redirects are not followed), takes longer than 30 seconds, or cannot be reached. A failed delivery is retried, up to 5 attempts in all:

AttemptWhen
1As soon as the event happens
21 minute after attempt 1
35 minutes after attempt 2
415 minutes after attempt 3
51 hour after attempt 4

After the fifth failure the delivery is marked failed and not retried again. Each attempt is signed afresh with the endpoint's current secret and timestamp, so a retry after you rotate the secret is signed with the new one.

Retries mean you can receive the same event more than once, and events can arrive out of order. De-duplicate on id, and when order matters, compare the envelope's timestamp or re-fetch the resource rather than trusting arrival order.

Events

Subscribe to events by exact name; there is no wildcard.

GroupEvents
Deliveriesdelivery.sent, delivery.delivered, delivery.bounced, delivery.complained, delivery.opened, delivery.clicked, delivery.suppressed, delivery.approved
Contactscontact.created, contact.updated, contact.deleted, contact.unsubscribed
Subscription groupssubscription-group.opted-in, subscription-group.opted-out
Segmentssegment.created, segment.updated, segment.deleted, segment.entered, segment.left
Journeysjourney.enrolled, journey.exited
Campaignscampaign.scheduled, campaign.sending, campaign.external-audience-fetched, campaign.sent, campaign.failed, campaign.deleted
Templatesemail-template.created, email-template.updated, email-template.deleted
Sender identitiessender-identity.created, sender-identity.updated
External audiencesexternal-audience.created, external-audience.updated, external-audience.deleted
Inbound mailinbound.parsed

The delivery, contact and journey events are described below. The rest carry the affected resource under data.

Delivery events

These follow a single email through its life, whether it was a campaign, a journey step or an API send. Each carries the delivery under data.delivery:

{
  "delivery": {
    "id": "…",
    "contactId": "…",
    "campaignId": null,
    "emailTemplateId": "…",
    "toAddress": "ada@example.com",
    "subject": "Reset your password",
    "status": "hard-bounced",
    "campaignRecipientKey": null,
    "externalReferenceId": null
  },
  "bounceType": "hard"
}

delivery.id is the same id POST /public/sending/mail returned, so this is how you match feedback to a send you made.

EventFires whenExtra fields
delivery.sentThe message was handed to the mail providerdelivery.renderedHTML, delivery.SESMessageID, delivery.data, delivery.templateData
delivery.deliveredThe recipient's server accepted it
delivery.bouncedIt bouncedbounceType: hard or soft
delivery.complainedThe recipient marked it as spam
delivery.opened / delivery.clickedThe recipient opened it or clicked a tracked link
delivery.suppressedIt was not sent, because of an unsubscribe, a group opt-out, the suppression list or the frequency caplane, suppressionReason
delivery.approvedA delivery held for approval was approved

A permanent hard bounce, a complaint, or a run of soft bounces to the same address also adds that address to the workspace suppression list, so later sends to it are suppressed rather than attempted.

Test and preview sends carry "isTest": true on data.delivery; real deliveries do not have the field at all.

Contact events

contact.unsubscribed

A contact opted out of all marketing mail from your workspace. Fires from every surface that performs a global unsubscribe — a marketer in the dashboard, the hosted subscription centre, an RFC 8058 one-click header in a mail client, DELETE /public/contact/:id, and a journey's Suppress step — and carries which one it was.

This is the event to mirror an opt-out into your own system. A contact.updated is not emitted for the status change, so this is the only signal that the contact stopped being mailable.

{
  "contact": {
    "id": "…",
    "email": "alice@example.com",
    "externalIdentifier": "crm-42",
    "status": "unsubscribed"
  },
  "deliveryId": "…",
  "source": "one-click"
}

status is always unsubscribed — it is included so the payload is self-contained rather than because it varies. deliveryId is the message the contact acted on, and is null on every surface where no specific message provoked the opt-out (the dashboard, the public API, a journey step).

source tells you which of those it was, and is worth branching on: an opt-out a contact performed themselves and one a journey decided for them are the same fact about mailability but not the same fact about intent.

The payload deliberately does not repeat the contact's properties, unlike contact.created and contact.updated — an opt-out changes no property, and shipping a copy here would be a second, staler source for data you already have. Re-fetch the contact if you need its current properties alongside the opt-out.

Whether a repeat unsubscribe re-fires depends on the surface. The event is emitted per unsubscribe request that reaches the service, not per change of status, and the surfaces guard differently:

  • The dashboard (POST /contact/:id/unsubscribe) and the public API (DELETE /public/contact/:id) return early when the contact is already unsubscribed, so repeating them emits nothing the second time.
  • The subscription centre, one-click and the journey Suppress node do not check first, so a contact who is already unsubscribed produces another event.

Either way, treat the event as "this contact is unsubscribed" rather than "this contact just became unsubscribed", and key your handler off id in the envelope so a retried delivery is not counted twice.

Unsubscribe sources

source is a fixed vocabulary. Treat an unrecognised value as an unsubscribe you should honour rather than failing the delivery — new surfaces can be added.

sourceThe contact was unsubscribed by…
dashboarda marketer, from the contact's page in Mailcrate.
subscription-centerthe contact, on the hosted preference page, choosing to stop all mail.
one-clickthe contact's mail client, through the RFC 8058 List-Unsubscribe header.
public-apiyour own integration, through DELETE /public/contact/:id.
journeya journey's Suppress step configured with reason: unsubscribe.

Records written before 2026-08-15

DELETE /public/contact/:id used to report itself as dashboard. Nothing was rewritten when that was corrected, so any record this route produced before 2026-08-15 carries the old value: suppression entries (source, and a note reading "Contact unsubscribed via dashboard") and the contact's unsubscribe activity row, whose metadata publishes the source verbatim. When auditing records from before that date, read dashboard as "a marketer or the public API"; from that date on the two are distinguishable.

A subscription-group opt-out is not this event

A contact who opts out of a single subscription group is still subscribed to everything else: their status does not change, they are not added to the suppression list, and no contact.unsubscribed is emitted. That case has its own event, subscription-group.opted-out, which names the group and carries its own source.

This matters when a delivery is tagged with a subscription group: the one-click header on that message opts the contact out of that group only, so you will see subscription-group.opted-out and no contact.unsubscribed. An untagged message's one-click header is a global unsubscribe and emits contact.unsubscribed with source: "one-click" as above. If your system keeps a single "do not mail" flag, drive it from contact.unsubscribed alone — acting on a group opt-out as though it were global suppresses an address the contact never asked you to stop mailing.

Journey events

Two events describe a contact's passage through a journey. They pair up: a contact that produced a journey.enrolled will produce exactly one journey.exited when that run ends — unless the run fails, or its journey is archived while the run is live, neither of which emits an exit at all. See Exit reasons below, and plan for a run that ends without one.

journey.enrolled

A contact entered a journey. Fires once per enrollment actually created — a duplicate trigger that was deduplicated, or an entry refused by a journey's re-entry policy, produces nothing.

{
  "journey": { "id": "…", "name": "Welcome series" },
  "contact": { "id": "…", "email": "alice@example.com" },
  "enrollment": { "id": "…" },
  "test": false
}

journey.exited

A contact left a journey. Fires once per enrollment, from every path that ends a live run as an audience transition — see Exit reasons for the two terminal outcomes that deliberately fire nothing.

{
  "journey": { "id": "…", "name": "Welcome series" },
  "contact": { "id": "…", "email": "alice@example.com" },
  "enrollment": { "id": "…" },
  "reason": "completed",
  "test": false
}

Exit reasons

reason is a fixed vocabulary. Treat an unrecognised value as "the run ended" rather than failing the delivery — new reasons can be added as the engine grows.

reasonThe run ended because…
completedthe contact reached the end of the journey. The ordinary outcome.
goalthe journey's goal event was recorded for this contact, and the journey is set to exit on goal.
unsubscribedthe contact unsubscribed, and the journey is set to exit on unsubscribe.
segment-leftthe contact left the journey's configured exit segment.
step-removeda new version was published and the step this contact was sitting on no longer exists.
contact-deletedthe contact was deleted. Sweeps every journey unconditionally — no per-journey setting is involved.
apiyou asked, through POST /public/journey/:id/exit.
rehearsal-displaceda test run stepped aside because the same contact entered the journey for real. Only ever a rehearsal — see below.

Note what is not here: a run that failed, or one canceled because its journey was archived, is an operational outcome rather than a contact leaving an audience, and does not emit journey.exited at all. Those runs end with no webhook of any kind, so if you track open enrollments, do not treat "entered" as a promise of an eventual "exited" — reconcile against the journey's enrollment list rather than waiting forever.

Test runs

test is true when the run is a rehearsal — the "enroll a test contact" action a marketer uses to walk a contact through a journey before it carries real traffic. Rehearsals send real mail and are real enrollments, so they are delivered like any other run, on both events.

If your integration should only act on real traffic, filter on test. It is present on journey.enrolled and journey.exited alike, so filtering one without the other is never necessary — and never advisable, since dropping an exit whose entry you accepted leaves a contact that, as far as your system knows, is still in the journey.

rehearsal-displaced is the one exit reason that is always a test run: a live rehearsal holds the contact's single live slot, so when that same contact enters the journey for real, the rehearsal ends to make way for the real run. You will never see it with test: false.

On this page