#Webhooks
Mittis POSTs a JSON event to your HTTPS endpoint whenever something happens: a text arrives, a message changes status, a call ends, a domain verifies. Each event carries its fields at the top level plus id (evt_...) and createdAt.
Create endpoints in the console or with the API:
curl https://api.mittis.ai/v1/webhooks \
-H "Authorization: Bearer $MITTIS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://app.example.com/webhooks/mittis", "events": ["message.received", "message.status"] }'The response includes the signing secret (whsec_...) once. Omit events, or pass ["*"], to receive everything. Send a sample signed delivery with POST /v1/webhooks/{id}/test, and rotate the secret with POST /v1/webhooks/{id}/rotate-secret (the old secret stops being used immediately).
#Verifying signatures
Every delivery has an X-Mittis-Signature header of the form t=<unix seconds>,v1=<hex>. v1 is the lowercase hex HMAC-SHA256, keyed with your endpoint secret, of the string "<t>.<raw request body>". Recompute it over the raw body, compare in constant time, and reject timestamps older than 5 minutes (replay protection). Do not parse and re-serialize the body before verifying.
import { verifyWebhookSignature, type MittisDeliveredWebhookEvent } from '@mittis/sdk';
export async function POST(req: Request) {
const raw = await req.text();
const valid = verifyWebhookSignature(
process.env.MITTIS_WEBHOOK_SECRET!,
req.headers.get('x-mittis-signature') ?? undefined,
raw,
);
if (!valid) return new Response('bad signature', { status: 401 });
const event = JSON.parse(raw) as MittisDeliveredWebhookEvent;
// event.type: message.received | message.status | call.completed | call.missed | ...
// event.id ("evt_...") and event.createdAt are on every event: dedupe on event.id.
return new Response('ok');
}parseWebhookEvent(secret, header, raw) does the verify and a typed parse in one call and throws on a bad signature.
Other headers, informational and not covered by the signature: Mittis-Event-Id, Mittis-Event-Type, Mittis-Delivery-Attempt (1 is the first try).
#Delivery guarantees
- At-least-once. Reply with any 2xx within 10 seconds. The same event can arrive more than once: dedupe on
id. It is stable across retries and equalseventIdinGET /v1/events. - Retries. Only transient failures are retried: timeouts, connection errors, HTTP 408, 409, 425, 429 and 5xx (
Retry-Afteron 429 and 503 is honored). The backoff is roughly 30s, 2m, 10m, 30m, 1h, 3h, 6h, 12h: 9 attempts over about 23 hours, then the delivery is abandoned. Each attempt is signed fresh; the body is the same event. Retries run from a 5-minute scheduler, so a retry fires on the next tick after it is due. - Permanent rejections. Any other 4xx (400, 401, 403, 404, 410, 422 and so on) and 3xx responses are abandoned immediately, not retried.
- Never retried.
call.ringingis attempted once, because a late retry would be stale. - High-volume events are best-effort. Events marked non-durable (currently
call.transcript.delta) get one attempt: no retry, no delivery log, and they are not inGET /v1/events. Use them for live views; the terminal event (call.transcript) is the durable record. - Auto-disable. Repeated consecutive failures disable an endpoint and fire
webhook_endpoint.disabled. - Delivery log.
GET /v1/webhooks/{id}/deliverieslists the last 7 days, newest first. Redeliver one withPOST /v1/webhooks/{id}/deliveries/{deliveryId}/retry.
#Writing a receiver that holds up
Mittis delivers at least once, with retries. These rules keep a receiver from doing the same work twice or losing events.
- Acknowledge first, work second. Verify the signature, record the event, return 2xx, then do the slow work (sending texts, calling other APIs, AI) in a background job. Mittis waits 10 seconds; a handler that works inline past that is retried while it is still running, so the work happens twice.
- Dedupe with an atomic claim on
event.id, not a lookup. "Select, then skip if already processed" lets two concurrent deliveries both pass. Insertevent.idinto a table with a unique constraint first; only the request whose insert succeeds does the work. Give the claim a lease (for example 5 minutes) so a crashed handler can be retried instead of locking the event out forever. - Never return 5xx after you have claimed the event. A 5xx tells Mittis to retry. If your side effects already ran, return 2xx and handle the failure yourself (log it, mark the claim failed, retry inside your own job queue). Return 5xx only when nothing has happened yet.
- Return 2xx for event types you don't handle. New event types are added over time, and you may stop handling old ones. A 4xx abandons the event and a 5xx retries it for about 23 hours, and every non-2xx counts toward auto-disabling the endpoint after repeated consecutive failures. Ignore unknown types and answer 2xx.
call.ringingcan fire more than once per call. It is sent per dialed softphone leg (each device, each ring-group wave), each with its ownevent.id. Dedupe onevent.idand key push notifications oncallId+deviceId. Never drop a ringing event just because you've seen itscallId.- Treat
call.completedandcall.missedas the record of a call, notcall.ringing(attempted once, never retried) orcall.transcript.delta(best-effort).
A minimal receiver following these rules:
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyWebhookSignature(SECRET, req.headers.get('x-mittis-signature') ?? undefined, raw)) {
return new Response('bad signature', { status: 401 }); // nothing claimed yet: rejecting is fine
}
const event = JSON.parse(raw) as MittisDeliveredWebhookEvent;
// Atomic claim: INSERT ... ON CONFLICT DO NOTHING on a unique event_id column.
const claimed = await claimEvent(event.id, { leaseMs: 5 * 60_000 });
if (!claimed) return new Response('duplicate', { status: 200 });
await queue.enqueue(event); // slow work happens in a background job
return new Response('ok', { status: 200 }); // 2xx for unknown types too
}#Polling the events feed
If you cannot receive inbound HTTP, GET /v1/events is a durable, ordered feed of the same events (this powers mittis listen). Start with after=now to get a cursor without any events, then pass each response's cursor as after. Events return oldest first by default; pass order=desc for newest first, paging back with before. Event ids match the id of the delivered webhook.
#Event catalog
This catalog is generated from the OpenAPI spec at build time. Every example is a complete payload. The full schema of each event is in the API reference.
message
#message.received
Inbound message received
Fired when a consumer texts one of your numbers. Fetch the full message with GET /v1/messages/{messageId}.
{
"type": "message.received",
"messageId": "8f14e45f-ceea-467a-9575-4d2f7b0e2a31",
"from": "+15555550142",
"to": "+15555550100",
"body": "Yes, Tuesday works!",
"receivedAt": "2026-07-01T15:09:41.000Z",
"providerMessageId": "msg_5f8e2c1a",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#message.status
Message delivery status changed
Fired as an outbound message moves through its delivery states (sent, delivered, failed, undelivered). reason carries a sanitized failure reason when the carrier reported one.
{
"type": "message.status",
"messageId": "8f14e45f-ceea-467a-9575-4d2f7b0e2a31",
"status": "delivered",
"reason": null,
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}call
#call.ringing
Softphone devices are being dialed
Fired per dialed softphone leg while an inbound call is ringing, so you can send a push notification that wakes a backgrounded app. Transient: it is attempted once and never retried, and the terminal call.completed / call.missed events remain the record. Never fired for SIP or PSTN-only routes.
{
"type": "call.ringing",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"from": "+15555550142",
"to": "+15555550100",
"devices": [
{
"deviceId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
"userId": "user_123"
}
],
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#call.completed
A call was answered and ended
Fired once when a call reaches its terminal state after being answered. direction is absent on older events; treat absence as inbound.
{
"type": "call.completed",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"from": "+15555550142",
"to": "+15555550100",
"answeredAt": "2026-07-01T15:00:09.000Z",
"endedAt": "2026-07-01T15:03:30.000Z",
"durationSeconds": 201,
"answeredBy": "Front desk",
"direction": "inbound",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#call.missed
A call ended without being answered
Fired when a call ends unanswered. outcome is voicemail when a machine picked up and nobody pressed 1. direction is absent on older events; treat absence as inbound.
{
"type": "call.missed",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"from": "+15555550142",
"to": "+15555550100",
"outcome": "missed",
"endedAt": "2026-07-01T15:00:30.000Z",
"direction": "inbound",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#call.ivr.key_pressed
A caller pressed an IVR menu key
Fired once per accepted keypress, before the key’s action runs. Invalid digits and timeouts never fire it.
{
"type": "call.ivr.key_pressed",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"numberId": "a3c1d2e4-5b6f-4a7b-8c9d-0e1f2a3b4c5d",
"digit": "1",
"label": "Sales",
"from": "+15555550142",
"to": "+15555550100",
"at": "2026-07-01T15:00:05.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#call.transcript
A recorded call has been transcribed
Fired exactly once per recorded call, seconds to minutes after the call ends. A failed transcription still delivers, with transcript: null and transcriptStatus: "failed".
{
"type": "call.transcript",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"recordingId": "4a3b2c1d-0e9f-4a8b-8c7d-6e5f4a3b2c1d",
"from": "+15555550142",
"to": "+15555550100",
"transcript": "Thanks for calling Acme Plumbing...",
"transcriptTurns": [
{
"speaker": "rep",
"text": "Thanks for calling Acme Plumbing."
}
],
"transcriptStatus": "available",
"durationSeconds": 201,
"recordingUrl": "/v1/calls/0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8/recording",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#call.transcript.delta
Live transcription of an in-progress call
Streamed while a call is bridged, only on routes with liveTranscription enabled. Best effort: delivered once, never retried, and not ordered, so sort a burst by at. Interim results (final: false) are superseded by the next result for the same speaker; only final: true results are stable turns. The terminal call.transcript remains the durable record.
{
"type": "call.transcript.delta",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"speaker": "caller",
"text": "I have a leak under my sink",
"final": true,
"at": "2026-07-01T15:00:20.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#call.routing.decision
Routing decision request (synchronous)
Unlike the events above this is a synchronous request: when a number has a decisionWebhook, Mittis POSTs here as the call opens, before any greeting or ring, and waits up to timeoutMs (default 800 ms) for your answer. It is signed with the same X-Mittis-Signature scheme and your endpoint secret. It is strictly fail-open: a timeout, non-200 status or malformed body runs the number’s normal route, so it can never fail a call. It is sent once and never retried.
{
"numberId": "a3c1d2e4-5b6f-4a7b-8c9d-0e1f2a3b4c5d",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"from": "+15555550142",
"to": "+15555550100",
"startedAt": "2026-07-01T15:00:00.000Z"
}voicemail
#voicemail.received
A voicemail was captured and transcribed
Fired once per captured voicemail, seconds to minutes after the call.missed with outcome voicemail. A failed transcription still delivers with transcript: null; the audio remains available at recordingUrl.
{
"type": "voicemail.received",
"callId": "0c7f3a52-91ab-4d6e-8f10-23b4c5d6e7f8",
"voicemailId": "9d8c7b6a-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"from": "+15555550142",
"to": "+15555550100",
"transcript": "Hi, this is Sam, calling about the leak under my sink.",
"transcriptStatus": "available",
"durationSeconds": 24,
"recordingUrl": "/v1/voicemails/9d8c7b6a-5e4f-4a3b-9c2d-1e0f9a8b7c6d/audio",
"receivedAt": "2026-07-01T16:20:00.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}billing
#billing.wallet.credited
A wallet credit landed
Fired to the paying account when a top-up or auto-recharge lands.
{
"type": "billing.wallet.credited",
"kind": "topup",
"amountCents": 5000,
"balanceCents": 7250,
"at": "2026-07-01T12:00:00.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#billing.wallet.low_balance
The wallet dropped below the recharge threshold
Fired once per threshold crossing. autoRechargeStarted is false when there is no saved card or auto-recharge is disabled.
{
"type": "billing.wallet.low_balance",
"balanceCents": 450,
"thresholdCents": 1000,
"autoRechargeStarted": true,
"at": "2026-07-01T12:00:00.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#billing.subscription.updated
The subscription status changed
Fired when the subscription status changes (active, past_due, canceled, ...).
{
"type": "billing.subscription.updated",
"status": "active",
"currentPeriodEnd": "2026-08-01T00:00:00.000Z",
"at": "2026-07-01T12:00:00.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}lead
#lead.captured
A brand’s hosted opt-in page was submitted
Carries the captured lead so you can route it into your own CRM or automation. consent reflects the SMS-consent checkbox and consentText is the exact language shown, for your audit trail.
{
"type": "lead.captured",
"leadId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"brandId": "2e5f3a41-8d7c-4b9a-a1b2-c3d4e5f60718",
"firstName": "Sam",
"lastName": "Rivera",
"email": "sam@example.com",
"phone": "+15555550142",
"consent": true,
"consentText": "I agree to receive text messages from Acme Plumbing.",
"capturedAt": "2026-07-01T11:45:00.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}webhook_endpoint
#webhook_endpoint.disabled
A webhook endpoint was automatically disabled
Fired when an endpoint is disabled after sustained failure (at least 50 consecutive failed attempts, no success in 72 hours, and a failing streak at least 72 hours old). It is delivered to your other enabled endpoints that subscribe to it and appears in GET /v1/events, never to the disabled endpoint itself. Re-enable with PATCH /v1/webhooks/{id} and {"enabled": true}.
{
"type": "webhook_endpoint.disabled",
"endpointId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"url": "https://example.com/webhooks/mittis",
"reason": "consecutive_failures",
"consecutiveFailures": 212,
"disabledAt": "2026-07-04T09:30:00.000Z",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}domain
#domain.verified
A sending domain was verified
Fired once when a sending domain becomes verified (including recovering from degraded). It can send from now on. Fetch it with GET /v1/domains/{domainId}.
{
"type": "domain.verified",
"domainId": "7b1c9d2e-3f4a-4b5c-8d6e-9f0a1b2c3d4e",
"domain": "example.com",
"status": "verified",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#domain.failed
A sending domain failed verification
Fired when a required DNS record is missing or wrong and the domain moves to failed. Fix the records and call POST /v1/domains/{domainId}/verify.
{
"type": "domain.failed",
"domainId": "7b1c9d2e-3f4a-4b5c-8d6e-9f0a1b2c3d4e",
"domain": "example.com",
"status": "failed",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#domain.degraded
A verified sending domain degraded
Fired when a previously verified domain has a required record that now fails. It cannot send until you fix DNS; it returns to verified automatically once the records recover.
{
"type": "domain.degraded",
"domainId": "7b1c9d2e-3f4a-4b5c-8d6e-9f0a1b2c3d4e",
"domain": "example.com",
"status": "degraded",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#domain.expired
An unverified sending domain was released
Fired when a domain that was never verified is released after 7 days. The domain no longer exists on your account; add it again to retry. Domains that were ever verified are never released automatically.
{
"type": "domain.expired",
"domainId": "7b1c9d2e-3f4a-4b5c-8d6e-9f0a1b2c3d4e",
"domain": "example.com",
"status": "expired",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.sent
An email left our servers for a recipient
Fired per recipient when the email has been handed to the recipient's mail server queue. Every email.* event is per recipient: an email to three people fires up to three of each. recipient is omitted for a bcc recipient we cannot tell apart from the email's other bcc recipients.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.sent",
"status": "sent",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.delivered
An email was delivered to a recipient
Fired per recipient when the recipient's mail server accepted the message.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.delivered",
"status": "delivered",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.deferred
Delivery to a recipient was delayed
Fired when the recipient's server temporarily refused the message. We keep retrying; a later email.delivered or email.bounced settles it.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.deferred",
"status": "deferred",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.bounced
An email bounced for a recipient
Fired when delivery to a recipient permanently failed. bounce.class is hard (the address does not exist; the address is added to your suppression list), soft, administrative, expired or unknown.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.bounced",
"status": "bounced",
"bounce": {
"class": "hard",
"code": 30001,
"message": "Mailbox does not exist"
},
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.failed
An email failed for a recipient
Fired when a message could not be sent to a recipient for a reason other than a bounce.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.failed",
"status": "failed",
"bounce": {
"class": "unknown",
"code": null,
"message": null
},
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.opened
A recipient opened an email
Fired when a recipient opens the email. Only sent when open tracking is enabled for your account; opens are not fully reliable (mail apps can pre-load images).
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.opened",
"status": "delivered",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.clicked
A recipient clicked a link
Fired when a recipient clicks a tracked link; url is the link. Only sent when click tracking is enabled for your account.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.clicked",
"status": "delivered",
"url": "https://example.com/estimate/1042",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.complained
A recipient reported the email as spam
Fired when a recipient marks the email as spam. The address is added to your suppression list and cannot be removed. Complaint rates above 0.2% pause your email sending.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.complained",
"status": "complained",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}#email.unsubscribed
A recipient unsubscribed
Fired when a recipient unsubscribes (method is link or one_click). The address is added to your suppression list; you can remove it if they opt back in.
{
"emailId": "3c8f1d62-7a4e-4b9d-9e21-5f6a7b8c9d0e",
"recipient": "dana@example.com",
"recipientKind": "to",
"occurredAt": "2026-07-01T15:04:09.000Z",
"tags": [
"estimate"
],
"metadata": {
"jobId": "job_1042"
},
"externalRef": null,
"type": "email.unsubscribed",
"status": "delivered",
"method": "one_click",
"id": "evt_6b8c2a14-3d5e-4f70-9a1b-2c3d4e5f6a7b",
"createdAt": "2026-07-01T15:09:42.000Z"
}