# Webhooks (/docs/webhooks)

Add webhook endpoints per environment in the dashboard (**Webhooks**) or through the API. Each
endpoint subscribes to event types (all of them by default) and has its own `whsec_…` signing
secret.

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
// ---cut---
const { data: endpoint, error } = await coss.webhookEndpoints.create({
  url: "https://example.com/webhooks/coss",
  event_types: ["email.delivered", "email.bounced"],
});
// Store the secret now: it is only returned when the endpoint is created (or its secret rolled).
const secret = endpoint?.secret;
```

## Events [#events]

| Type                     | Sent when                                               |
| ------------------------ | ------------------------------------------------------- |
| `email.sent`             | The provider accepted the email                         |
| `email.delivered`        | A recipient's mail server accepted it                   |
| `email.delivery_delayed` | Delivery to a recipient is being retried                |
| `email.bounced`          | A recipient's mail server rejected it                   |
| `email.complained`       | A recipient marked it as spam                           |
| `email.failed`           | The email could not be sent at all                      |
| `email.suppressed`       | A recipient was skipped because it is suppressed        |
| `email.scheduled`        | An email was [scheduled](/docs/email/sending#scheduling-rescheduling-and-cancellation) for later |
| `email.rescheduled`      | A scheduled email got a new time (`data.reschedule`)    |
| `email.canceled`         | A scheduled email was canceled                          |
| `email.opened`           | A recipient opened it ([open tracking](/docs/email/tracking)) |
| `email.clicked`          | A recipient clicked a tracked link ([click tracking](/docs/email/tracking)) |
| `email.unsubscribed`     | A recipient used [one-click unsubscribe](/docs/email/unsubscribe) |
| `email.received`         | An email arrived at one of the environment's [receiving addresses](/docs/email/receiving) |
| `inbox.message.received` | An email arrived in one of the environment's [inboxes](/docs/inboxes) |
| `contact.created`        | A [contact](/docs/email/contacts) was added (one at a time: imports send no events) |
| `contact.updated`        | A contact's names, status, properties or topics changed |
| `contact.deleted`        | A contact was deleted                                   |
| `contact.unsubscribed`   | A contact unsubscribed from all marketing email, or from one topic (`topic`), through the [preference center](/docs/email/topics-and-preferences) or one-click unsubscribe |
| `contact.confirmed`      | A contact confirmed their address ([double opt-in](/docs/email/topics-and-preferences#double-opt-in)) |
| `broadcast.sending`      | A [broadcast](/docs/email/broadcasts) started sending   |
| `broadcast.sent`         | A broadcast finished: every eligible contact was processed and none of its emails is still waiting (failures are in its `stats`) |
| `broadcast.paused`       | A broadcast was paused, by you or by coss.com (`pause_reason`) |
| `broadcast.canceled`     | A scheduled, sending or paused broadcast was canceled   |
| `broadcast.failed`       | A broadcast stopped for good (`error`)                  |
| `sms.scheduled`          | An [SMS](/docs/sms/sending) was scheduled               |
| `sms.sent`               | The carrier accepted an SMS                             |
| `sms.delivered`          | The carrier delivered it to the phone                   |
| `sms.undelivered`        | The carrier couldn't deliver it (`data.failure`)        |
| `sms.failed`             | The SMS could not be sent at all (`data.failure`)       |
| `sms.suppressed`         | The SMS was skipped because its number is [suppressed](/docs/sms/sending#suppressions) (`data.suppression`) |
| `sms.canceled`           | A scheduled SMS was canceled                            |
| `sms.received`           | An SMS arrived at a number whose replies go to this environment ([receiving](/docs/sms/receiving)) |
| `sms.opted_out`          | A number replied STOP and was suppressed in this environment |
| `sms.opted_in`           | A number replied START and its opt-out was removed      |
| `sign_request.sent`      | A signature request was sent (created with `send`, or a draft sent) |
| `sign_request.viewed`    | A signer opened it for the first time (`data.signer`)   |
| `sign_request.signer_signed` | A signer signed (`data.signer`); `data.status` is `completed` when they were the last |
| `sign_request.declined`  | A signer declined, which declines the request (`data.reason`) |
| `sign_request.completed` | Everyone signed and the sealed, signed document is ready to download |
| `sign_request.canceled`  | You canceled it                                         |
| `sign_request.expired`   | It passed its `expires_at` before everyone signed       |
| `verification.approved`  | The right code was checked (billed once)                |
| `verification.failed`    | Too many wrong codes, or the code couldn't be sent (`data.failure`) |
| `verification.blocked`   | The fraud guard stopped it before a code was sent (`data.reasons`) |
| `form.submission.received` | A [form](/docs/forms) submission was accepted (`data.data`: what was submitted) |
| `form.submission.flagged` | Risk held a submission for review or refused it as spam (`data.spam_reason`) |
| `scheduling.booking.confirmed` | A [booking](/docs/scheduling/bookings) was confirmed. Ids, times and statuses only: retrieve it for the attendee |
| `scheduling.booking.pending_approval` | A booking is waiting for the host's approval |
| `scheduling.booking.rescheduled` | A booking moved (`data.previous_start`) |
| `scheduling.booking.cancelled` | A booking was cancelled |
| `scheduling.booking.declined` | A booking was declined, or its approval deadline passed |
| `scheduling.booking.no_show` | An attendee was marked as a no-show |
| `scheduling.booking.external_conflict` | Something else landed on a booked time, or the host changed the event on their calendar |
| `scheduling.booking.calendar_write_failed` | A booking couldn't be written to the host's calendar. It still stands |
| `scheduling.booking.location_ready` | A booking's meeting link is ready |
| `scheduling.booking.reassigned` | A booking went to [another host](/docs/scheduling/reassigning) (`data.previous_host_ids`) |
| `scheduling.reassignment.completed` | [Many bookings](/docs/scheduling/reassigning#many-at-once) finished moving, or that was cancelled (`data.counts`) |
| `scheduling.booking.started` | A confirmed booking's start time arrived. It doesn't mean anyone joined |
| `scheduling.booking.ended` | A confirmed booking's end time arrived |
| `scheduling.booking.reminder` | A [reminder](/docs/scheduling/reminders#webhooks-at-any-moment) you timed came due (`data.reminder`) |
| `scheduling.calendar.connected` | A host [connected a calendar](/docs/scheduling/calendars) |
| `scheduling.calendar.needs_reauth` | A host's calendar lost access: they aren't offered until it's back |
| `scheduling.calendar.disconnected` | A host disconnected a calendar |
| `scheduling.out_of_office.created` | A host was [marked as away](/docs/scheduling/out-of-office). Ids and times only: never why |
| `scheduling.out_of_office.updated` | A host's time away changed |
| `scheduling.out_of_office.deleted` | A host's time away was removed |
| `scheduling.out_of_office.started` | A host's time away began |
| `scheduling.out_of_office.ended` | A host's time away ended |
| `risk.assessment.review` | A [risk assessment](/docs/risk) decided `review`        |
| `risk.assessment.reviewed` | Feedback resolved a `review`                          |
| `hook_endpoint.disabled` | coss.com disabled one of your [Hooks](/docs/hooks/delivery#failing-endpoints-are-disabled) applications' endpoints after its deliveries failed for 5 days |

### Sub-account events [#sub-account-events]

A [sub-account](/docs/email/subaccounts)'s own reputation changes go to its environment's endpoints
(live only). `data.subaccount_id` and `data.uid` name it.

| Type                              | Sent when                                                                   |
| --------------------------------- | --------------------------------------------------------------------------- |
| `subaccount.reputation_warning`   | Its bounce or complaint rate crossed the warning threshold                  |
| `subaccount.reputation_recovered` | The warning cleared                                                         |
| `subaccount.paused`               | Its live sending was paused (a rate, or content screening: `data.trigger`)  |
| `subaccount.reinstated`           | You reinstated it                                                           |

### Organization events [#organization-events]

These concern the whole organization, so every **live** environment's endpoints receive them
(sandbox endpoints don't). `data.organization_id` names the organization.

| Type                              | Sent when                                                                               |
| --------------------------------- | --------------------------------------------------------------------------------------- |
| `domain.dmarc_report_received`    | A [DMARC](/docs/email/dmarc) aggregate report about a domain arrived (a summary, no rows) |
| `account.reputation_warning`      | A reputation check (bounces, complaints, content) crossed its warning threshold         |
| `account.reputation_recovered`    | The warning cleared                                                                     |
| `account.suspended`               | Live sending was suspended                                                              |
| `account.reinstated`              | The suspension was lifted                                                               |
| `account.marketing_paused`        | The marketing stream's bounce or complaint rate paused marketing sends (`data.marketing`) |
| `account.marketing_reinstated`    | The marketing pause was lifted                                                          |
| `billing.invoice_finalized`       | An invoice was issued (`data.invoice`, without lines)                                   |
| `billing.invoice_paid`            | An invoice was paid in full                                                             |
| `billing.invoice_payment_failed`  | Charging an invoice failed (`data.failure.message`); it is retried                      |
| `billing.invoice_voided`          | An open invoice was voided                                                              |
| `billing.plan_changed`            | The plan changed or a change was scheduled or undone (`change`, `plan`, `effective_at`) |
| `billing.usage_threshold_reached` | Usage reached an alert threshold of the month's credit or Free's allowance (`percent`)  |
| `billing.hold_placed`             | Live sending was paused for billing (`reason`: `delinquent`, `spend_limit`, `credit_limit` or `admin`) |
| `billing.hold_lifted`             | That pause ended                                                                        |
| `billing.credit_granted`          | A credit was added (`data.credit`)                                                      |
| `billing.budget_threshold_reached` | A member's, project's or API key's spend reached an alert threshold of its monthly budget (`scope`, `target_id`, `percent`) |
| `billing.budget_hold_placed`      | Its budget is used up: its live sending is paused (`scope`, `target_id`)                |
| `billing.budget_hold_lifted`      | That pause ended (a new month, or a higher budget)                                      |

See [Plans and billing](/docs/billing#billing-api-and-events) for what they mean and how to read
the rest of an organization's billing.

The request body is a JSON event. Payloads describe the email (`email_id`, `from`, `to`, `subject`,
`tags`, `category`) but never include its content, headers, cc or bcc; `email.received` events
likewise only name the received email (`received_email_id`) and who sent it to whom. `sms.*`
events never include the text either: read it with `coss.sms.get()` or `coss.sms.received.get()`. `sign_request.*`
events never include documents or what signers filled in. Events are
rendered in the
endpoint's [API version](/docs/api-reference/versioning#webhooks), named in `api_version`.

```json
{
  "id": "evt_01k7c8m4q2f3e9v6t5r1n0p8yz",
  "object": "event",
  "type": "email.delivered",
  "api_version": "2026-10-10",
  "created_at": "2026-10-10T12:00:01.000Z",
  "data": {
    "email_id": "email_01k7c8m4q2f3e9v6t5r1n0p8yz",
    "from": "Acme <hello@acme.com>",
    "to": ["user@example.com"],
    "subject": "Welcome",
    "tags": [],
    "category": null,
    "sandbox": false,
    "recipient": "user@example.com",
    "occurred_at": "2026-10-10T12:00:00.000Z",
    "delivery": { "smtp_response": "250 2.0.0 OK" }
  }
}
```

## Verifying requests [#verifying-requests]

Requests are signed with [Standard Webhooks](https://www.standardwebhooks.com) headers:
`webhook-id`, `webhook-timestamp` and `webhook-signature`. Always verify them before trusting the
body. With the SDK, use [`verifyWebhook`](/docs/sdk/webhooks).

## Testing locally [#testing-locally]

Your development server isn't reachable from the internet, so register no endpoint for it: run
[`coss listen --forward-to localhost:3000/webhooks`](/docs/cli#receive-webhooks-locally), which receives the
environment's events and forwards them to your server, signed with a secret of its own that you
verify the same way.

To try a handler without making the real thing happen, send a **test event** with
[`coss trigger`](/docs/cli#trigger-test-events) or the API: a sample event of that type, sent to
the environment's subscribed endpoints and open listeners exactly like a real one (same body,
signature, retries and log lines). Test events are for sandbox environments only, and of every
type except the live-only `domain.*`, `account.*` and `billing.*`. Nothing in the body marks them as
tests, and the ids in the sample are made up: pass real ones in `data` if your handler looks them
up.

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
// ---cut---
const { data } = await coss.webhookEvents.test({
  type: "email.bounced",
  data: { recipient: "ada@example.com" },
});
console.log(data?.event.id, data?.endpoints, data?.listeners);
```

## Rolling the secret [#rolling-the-secret]

Roll an endpoint's secret in the dashboard (**Roll secret** on the endpoint) or through the API to
replace it, for example on a schedule or after it may have leaked. For a grace period (a day by
default, up to a week) every request carries two signatures, one per secret, in
`webhook-signature`, so a receiver verifying with either one keeps accepting requests while you
deploy the new secret. Choose `0` to stop the old secret at once.

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
declare const endpointId: string;
// ---cut---
const { data: rolled } = await coss.webhookEndpoints.rollSecret(endpointId, {
  previous_secret_expires_in: 60 * 60, // the old secret keeps signing for an hour
});
const newSecret = rolled?.secret; // deploy it before rolled?.previous_secret_expires_at
```

Rolling again during the grace period stops the older secret immediately. The SDK never retries
a roll: if the response is lost, roll again and deploy that secret.

## Delivery and retries [#delivery-and-retries]

- Respond with any `2xx` status within 10 seconds to acknowledge an event.
- Failed deliveries are retried 7 times over about 28 hours (5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h).
- Events can arrive out of order: sort by `data.occurred_at` (every event has it).
- An event can arrive more than once: deduplicate by `id` (equal to the `webhook-id` header).
- Events about an email or text message sent with a `traceparent` header carry it in
  `data.traceparent` and as the request's own `traceparent` header (see [Tracing](/docs/api-reference/tracing)).

## Delivery logs [#delivery-logs]

Every event sent to an endpoint is logged as a delivery, with each HTTP attempt (status code,
duration, error and the first 1 KB of your response) and the payload as sent. Find them on the
endpoint's page in the dashboard, or list them through the API — newest first, filtered by
`status` or `event_id`:

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
const endpointId = "whep_…";
// ---cut---
const { data: failed } = await coss.webhookEndpoints.deliveries.list(endpointId, {
  status: "failed",
});

// After fixing your receiver, send a failed event again.
for (const delivery of failed?.data ?? []) {
  await coss.webhookEndpoints.deliveries.retry(endpointId, delivery.id);
}
```

A retry makes one more attempt within seconds, signed with the endpoint's current secret and sent
to its current URL. Deleting an endpoint deletes its delivery log, and deliveries are deleted
30 days after the event (see [data retention](/docs/data-retention)).
