Skip to content

Webhooks

A webhook tells your server when a grant, or the name or address behind it, changes, so you do not have to poll. Events carry ids and what changed, never the name or address itself: read the grant with GET /v1/grants/{id} for the current data.

Register an endpoint from the business console or with the API:

Terminal window
curl https://api.mkey.ai/v1/webhook-endpoints \
--header "Authorization: Bearer $MAILKEY_API_KEY" \
--header "Content-Type: application/json" \
--data '{"url": "https://hooks.example.com/mailkey", "events": ["grant.revoked", "address.changed"]}'

Leave out events to receive every type. The answer includes the endpoint’s signing secret, whsec_ followed by base64. It is shown only once; store it with your other secrets.

  • The URL must be https on port 443, and its host must resolve only to public addresses.
  • Redirects are not followed.
  • A business can have at most 10 endpoints.
  • POST /v1/webhook-endpoints/{id}/test sends a webhook.test event and tells you what your endpoint answered.
Type When
grant.approved The person approved a claim that was waiting for them. The grant is now active; read it for the name and address.
grant.denied The person declined a claim that was waiting for them.
grant.revoked The grant ended without you releasing it: the person revoked it, a move or key replacement left your business out, or the person deleted their account. Delete the name and address you hold.
address.change_scheduled The person scheduled a move that keeps your grant. detail.effective_date is the day it takes effect; until then the grant shows upcoming_address.
address.changed The person’s current address changed, because a move took effect or they corrected it. Read the grant for the new address.
address.confirmed The person confirmed their address is still current, including after a returned-mail report.
name.changed The person edited their name; detail.fields lists what changed. Read the grant for the name exactly as typed.

Each event is a POST with a JSON body:

{
"type": "address.change_scheduled",
"timestamp": "2026-10-02T09:41:27.000Z",
"data": {
"event_id": "0199a3e2-4d5e-7f60-b17c-8d9e0f1a2b34",
"grant_id": "0199a3b2-7d8e-7f90-a1b2-c3d4e5f6a7b8",
"external_ref": "CUST-1042",
"detail": { "effective_date": "2026-11-01" }
}
}

timestamp is when the change happened. external_ref is the reference you gave when you claimed the grant. detail depends on the type:

Type detail
grant.revoked status, reason (for example person) and previous_status
address.change_scheduled, address.changed effective_date
name.changed fields, the name fields that changed
Others Empty

Each event type is in the API reference under Webhook events.

MailKey signs every delivery per the Standard Webhooks specification. Each request carries three headers:

Header Value
webhook-id The message id, the same on every retry of one delivery
webhook-timestamp Unix seconds when this attempt was signed
webhook-signature v1, and the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}

The HMAC key is the secret’s bytes: drop the whsec_ prefix and base64-decode the rest. Verify against the raw request body, before parsing it, and compare signatures in constant time. Any Standard Webhooks library does this for you, for example standardwebhooks on npm and PyPI:

import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.MAILKEY_WEBHOOK_SECRET);
// Throws when the signature or timestamp is wrong.
const event = wh.verify(rawBody, {
"webhook-id": request.headers.get("webhook-id"),
"webhook-timestamp": request.headers.get("webhook-timestamp"),
"webhook-signature": request.headers.get("webhook-signature"),
});

Or with the Web Crypto API alone:

async function verify(secret: string, headers: Headers, rawBody: string): Promise<boolean> {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const keyBytes = Uint8Array.from(atob(secret.replace(/^whsec_/, "")), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, [
"verify",
]);
const signed = new TextEncoder().encode(`${id}.${timestamp}.${rawBody}`);
for (const entry of signatures.split(" ")) {
const [version, value] = entry.split(",");
if (version !== "v1" || !value) continue;
const mac = Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
if (await crypto.subtle.verify("HMAC", key, mac, signed)) return true;
}
return false;
}

crypto.subtle.verify compares in constant time. Rejecting timestamps more than five minutes old stops an attacker from replaying a captured request.

  • Answer any 2xx within 10 seconds to acknowledge an event. Do the work afterwards, for example on a queue.
  • Any other status, a redirect, a timeout or a network error counts as a failure. MailKey retries after 30 seconds, doubling the wait each time up to 4 hours, for 24 hours.
  • While deliveries are failing, the endpoint shows failing_since. A success clears it.
  • If a delivery still fails after 24 hours, the endpoint is disabled (disabled_at is set) and the console flags it. Nothing more is sent until a test event succeeds.
  • Delivery is at least once, so the same event can arrive twice. webhook-id stays the same across retries; use it to drop duplicates.
  • Events can arrive out of order. Read the grant for the current state rather than replaying events.

You receive an event when something changes that your business can see on the grant, the same changes the console’s Changes view lists. A grant you released stops producing events.