Guides
Verifying webhooks
Mailpark POSTs events to your endpoint as they happen. Each request is signed with your webhook's secret, so you can check it really came from Mailpark.
Add an endpoint
Create a webhook with your URL and the event kinds you want. The response holds signing_secret, shown only once: save it as MAILPARK_WEBHOOK_SECRET. Then send yourself a sample with the test endpoint.
const wh = await mailpark.webhooks.create({
url: "https://acme.dev/api/mailpark-webhook",
event_kinds: ["email.bounced", "email.complained", "email.unsubscribed"],
});
// Save wh.signing_secret, then:
await mailpark.webhooks.test(wh.id);The payload
POST to your URL
{
"id": "evt_5Rc2",
"kind": "email.bounced",
"created_at": "2026-10-22T10:02:11Z",
"data": {
"message_id": "msg_01JB4Q7Z2K",
"type": "transactional",
"to": "jane@example.com",
"contact_id": "con_7Yt1",
"template": "password-reset",
"bounce_type": "hard",
"environment": "live"
}
}| Header | Value |
|---|---|
X-Signature | t=<unix seconds>,v1=<hex> |
X-Mailpark-Event-Id | The event id, same as the body's `id`. |
X-Mailpark-Event-Kind | The event kind. |
X-Mailpark-Delivery-Id | This delivery attempt. |
Verify the signature
v1 is HMAC-SHA256 of `${t}.${rawBody}` with your signing secret, as hex. Check it against the raw request body, compare in constant time, and reject timestamps more than five minutes from now.
import { verifyWebhook } from "mailpark";
// app/api/mailpark-webhook/route.ts
export async function POST(req: Request) {
const raw = await req.text(); // the raw body, not re-serialised JSON
let event;
try {
event = await verifyWebhook(raw, req.headers.get("x-signature"), process.env.MAILPARK_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 400 });
}
// Retries can deliver the same event twice: skip ids you've already handled.
if (await alreadyHandled(event.id)) return new Response("ok");
if (event.kind === "email.bounced") {
await markEmailInvalid(event.data.to);
}
return new Response("ok");
}Retries
- Answer with any 2xx within a few seconds. Do slow work after responding.
- Anything else is retried with backoff for up to 24 hours. A webhook that keeps failing is marked failing, and the team is told.
- Deduplicate on the event
id: a retry carries the same id. - See every attempt with GET /v1/webhooks/{id}/deliveries, and resend one by hand.
Event kinds
| Kind | When |
|---|---|
email.sent | Handed to the inbox provider. |
email.delivered | The provider accepted it. |
email.bounced | It bounced. bounce_type is hard or soft. |
email.complained | The recipient marked it as spam. |
email.opened | Opened. likely_automatic marks machine opens such as Apple Mail Privacy Protection. |
email.clicked | A link was clicked. link is the URL. |
email.unsubscribed | The recipient unsubscribed. |
sequence.entered | A contact entered a sequence. |
sequence.exited | A contact left a sequence. |
broadcast.held | A broadcast was held for review. |
account.paused | Sending was paused on the account. |
Test mode
Events from test keys are delivered too, with
environment: "test" in data. Send to bounce@example.com to get an email.bounced.