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.
Add an endpoint
Section titled “Add an endpoint”Register an endpoint from the business console or with the API:
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
httpson 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}/testsends awebhook.testevent and tells you what your endpoint answered.
Event types
Section titled “Event types”| 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. |
Payload
Section titled “Payload”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.
Verify the signature
Section titled “Verify the signature”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, retries and duplicates
Section titled “Answer, retries and duplicates”- Answer any
2xxwithin 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_atis 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-idstays 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.
What you receive
Section titled “What you receive”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.