Webhooks
Webhook events
MailCraft sends a signed POST to your endpoint when something happens to an email or contact. Pick the event types you care about when you register the endpoint — you only receive those.
Register an endpoint
Add one under Webhooks in the dashboard, or with the webhooks API. The URL must be public HTTPS, and events lists the event types to deliver. The response includes the signing secret — it's shown only once, so store it.
curl https://api.mailcraft.host/v1/webhooks \
-X POST \
-H "Authorization: Bearer $MAILCRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/mailcraft",
"events": ["email.delivered", "email.bounced", "email.clicked"]
}'Payload
Every delivery is JSON with the same envelope: type is the event type, created_at is when the delivery was created, and data depends on the event — see each type below.
{
"type": "email.clicked",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": { ... }
}Verify signatures
Each request carries an X-Sendkit-Signature header like t=1695393068,v1=5f8c…. v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with your endpoint's secret. Recompute it and compare before trusting the payload.
import crypto from 'node:crypto';
// Use the raw request body — not a re-serialised JSON object.
export function verifyMailCraftSignature(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const valid =
expected.length === (parts.v1 ?? '').length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
// Reject replays of old deliveries.
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return valid && fresh;
}Responses and retries
Respond with any 2xx within 5 seconds. Anything else — an error, a timeout, or a redirect, which is never followed — counts as a failure. Each delivery is attempted up to five times in total, waiting this long before each retry:
- 1 minute
- 5 minutes
- 30 minutes
- 2 hours
Deliveries can arrive more than once and out of order, so make your handler idempotent — the email or contact id plus the type identify an event.
Event types
| email.sent | The email was accepted for delivery by the mail server. |
| email.delivered | The recipient's mail server accepted the email. |
| email.opened | The recipient opened the email. Sent every time it is opened; requires open tracking on the domain. |
| email.clicked | The recipient clicked a tracked link. Sent for every click; requires click tracking on the domain. |
| email.bounced | The email bounced permanently. The address is added to your suppression list. |
| email.complained | The recipient marked the email as spam. The address is added to your suppression list. |
| email.unsubscribed | The recipient unsubscribed from the email. |
| email.delayed | Delivery is being retried after a temporary failure at the recipient’s server. |
| email.failed | The email could not be sent. |
| email.rejected | The email was rejected before it was sent. |
| contact.created | A contact was added to the workspace. |
| contact.updated | A contact's details or properties changed. |
| contact.deleted | A contact was deleted. |
Email events
email.sent
The email was accepted for delivery by the mail server.
{
"type": "email.sent",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.delivered
The recipient's mail server accepted the email.
{
"type": "email.delivered",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.opened
The recipient opened the email. Sent every time it is opened; requires open tracking on the domain.
{
"type": "email.opened",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": {
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
},
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.clicked
The recipient clicked a tracked link. Sent for every click; requires click tracking on the domain.
{
"type": "email.clicked",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": {
"link_id": "01J9Z9C4E6G8J0L2N4Q6S8U0WY",
"url": "https://acme.com/pricing",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
},
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.bounced
The email bounced permanently. The address is added to your suppression list.
{
"type": "email.bounced",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.complained
The recipient marked the email as spam. The address is added to your suppression list.
{
"type": "email.complained",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.unsubscribed
The recipient unsubscribed from the email.
{
"type": "email.unsubscribed",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.delayed
Delivery is being retried after a temporary failure at the recipient’s server.
{
"type": "email.delayed",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.failed
The email could not be sent.
{
"type": "email.failed",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}email.rejected
The email was rejected before it was sent.
{
"type": "email.rejected",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z8K2M4Q6R8T0V2X4Y6Z8AB",
"to": [
"[email protected]"
],
"subject": "Welcome to Acme",
"metadata": null,
"occurred_at": "2026-09-22T14:31:07.000000Z"
}
}Contact events
contact.created
A contact was added to the workspace.
{
"type": "contact.created",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z6Q4M7X2K9R3T5V8W1Y4ZB",
"email": "[email protected]"
}
}contact.updated
A contact's details or properties changed.
{
"type": "contact.updated",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z6Q4M7X2K9R3T5V8W1Y4ZB",
"email": "[email protected]"
}
}contact.deleted
A contact was deleted.
{
"type": "contact.deleted",
"created_at": "2026-09-22T14:31:08.000000Z",
"data": {
"id": "01J9Z6Q4M7X2K9R3T5V8W1Y4ZB",
"email": "[email protected]"
}
}