# iSnap Webhooks

[iSnap](https://isnap.ai) is an iMessage and SMS line API: messages, reactions, typing indicators, lines (BYOD, rented and shared), trials, pre-orders and hosted macOS sessions. You create a subscription with `POST /v1/webhooks` (a `url`, an `events` filter and optional `line_ids`), and iSnap POSTs one signed JSON event per request to that URL. Signatures follow Standard Webhooks, so each delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers.

## When to Use This Skill

* How do I receive iSnap webhooks?
* How do I verify an iSnap webhook signature (`webhook-signature`)?
* Where do I get my iSnap webhook secret (`whsec_...`)?
* How do I handle `message.received`, `message.delivered`, `message.failed` or `line.offline` events?
* Why is my iSnap webhook verification failing, or failing only on retries and replays?
* Why was my iSnap subscription turned off or suspended?

## Verification (core)

| Header | Value |
| --- | --- |
| `webhook-id` | The event's `event_id` (`evt_...`), the same on every attempt and replay. Your idempotency key |
| `webhook-timestamp` | Unix seconds when this attempt was sent; changes on every attempt |
| `webhook-signature` | `v1,<base64>` (one or more space-separated entries) |

Signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256, base64 output. The key is the base64-**decoded** part of the `whsec_...` secret after the prefix (48 bytes; not hex-decoded, not the string itself). Compare against every `v1,` entry and ignore other versions. If `webhook-id` is absent, read `svix-id`, `svix-timestamp` and `svix-signature` instead, as Hookdeck does. The examples use the `standardwebhooks` library (npm and PyPI):

```javascript
const { Webhook } = require('standardwebhooks');

// rawBody: exact request body (Buffer or string), NOT re-serialised JSON
function verifyIsnap(rawBody, headers, secret = process.env.ISNAP_WEBHOOK_SECRET) {
  const p = headers['webhook-id'] ? 'webhook' : 'svix'; // fall back to the svix-* names
  new Webhook(secret).verify(rawBody, {
    'webhook-id': headers[`${p}-id`],
    'webhook-timestamp': headers[`${p}-timestamp`],
    'webhook-signature': headers[`${p}-signature`],
  }, { jsonParse: false }); // throws on a bad signature or stale timestamp → respond 401
}

```

The library always enforces a fixed **5-minute timestamp window**. iSnap stamps every attempt with a fresh `webhook-timestamp`, so direct deliveries always pass. Hookdeck retries and replays, however, resend the original `webhook-timestamp`, so the library rejects them once they are more than 5 minutes old. If you receive through Hookdeck and need late retries to pass, use the manual verifier in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/isnap-webhooks/references/verification.md), which can drop or widen the window. In Express, read the body with `express.raw({ type: '*/*' })` so any content type verifies, and parse JSON only after the check.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/isnap-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/isnap-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/isnap-webhooks/examples/fastapi/).

**Never answer `410` to a failed verification:** iSnap treats a `410` as "gone" and turns the subscription off immediately. Use `400` for missing headers and `401` for a bad signature.

## Event Type and Payload

Every body is the same envelope, and the event name is the top-level `event_type` (there is no `type` or `id` field):

```json
{
  "api_version": "v1",
  "event_id": "evt_Fh50GcqYY37ay4VjUxQ2rR6jN",
  "event_type": "message.received",
  "created_at": "2026-10-08T14:22:57.143Z",
  "trace_id": "174b4a27e4e4938fa67ea87b23a18579",
  "data": { "message": { "id": "msg_...", "body": "..." }, "metadata": null, "line_metadata": null }
}

```

`event_id` equals the `webhook-id` header and is the dedupe key. `data` always holds `metadata` and `line_metadata` (the `metadata` you attached to the resource and to the line). The unsigned `X-iSnap-Event` header repeats the type, but read the type from the verified body.

## Common Event Types

| Event | Fired when |
| --- | --- |
| `message.received` | An inbound message is stored |
| `message.queued` / `message.scheduled` | A message is accepted for sending, or accepted and held |
| `message.sent` | The line's device confirms it sent a message |
| `message.delivered` / `message.read` | A message is delivered, or read by the recipient |
| `message.failed` | A send fails |
| `message.cancelled` | You cancel a queued or held message |
| `message.fallback_triggered` | A message went out over SMS or RCS instead of iMessage |
| `reaction.added` / `reaction.received` / `reaction.removed` | A reaction you added is confirmed, a contact reacts, or a reaction is removed |
| `typing_indicator.started` / `typing_indicator.stopped` | A contact starts or stops typing (never on iMessage lines) |
| `line.connected` / `line.disconnected` / `line.offline` / `line.degraded` | Line connectivity changes |
| `line.apple_id_flagged` | Apple disabled or restricted the line's Apple ID |
| `line.quota_warning` / `line.quota_exceeded` | A sending limit reaches 80% / 100% |
| `line.capability_changed` / `line.terminated` | A BYOD capability changes, or a line is terminated |
| `line.variant_changed` / `line.variant_scheduled` / `line.variant_unscheduled` | Rented line inbound-only/two-way changes |
| `trial.linked` / `trial.dormant` / `trial.reactivated` / `trial.bind_conflict` / `trial.revoked` | Trial lifecycle |
| `pre_order.fulfilled` / `pre_order.cancelled` | A pre-order is handed over or ends early |
| `macos_session.rented` / `macos_session.released` | A hosted macOS session is assigned or released |
| `binding.released` | A shared-line binding with one contact ends |
| `webhook.test` | A probe to a suspended subscription (signed; send `2xx` to resume it) |

iSnap documents 37 event types (the list may not be exhaustive); the examples route all of them. Ignore and acknowledge types you don't recognise. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/isnap-webhooks/references/overview.md).

## Delivery Semantics

Return a `2xx` within **10 seconds** with an empty body, then process asynchronously. Everything else is a failed attempt and is retried: `3xx` (redirects are never followed), `4xx` including `429`, `5xx`, timeouts and network errors; `Retry-After` is ignored. A schedule has up to 7 attempts (30 s, 1 min, 5 min, 30 min, 2 h, 12 h after the previous one, about 14.6 hours in all). After 10 deliveries in a row exhaust their attempts the subscription is **suspended** and resumes only when a signed `webhook.test` probe (`X-iSnap-Probe: 1`) gets a `2xx`; events during a suspension are lost. Delivery is at least once and unordered: deduplicate on `event_id` and order by timestamps inside `data`. iSnap publishes no source IP list: the signature is the only proof of origin.

## Environment Variables

```bash
ISNAP_WEBHOOK_SECRET=whsec_xxxxx   # full value from POST /v1/webhooks; handlers fail closed (500) without it

```

Each subscription has its own secret (returned once, on create). The examples also accept a comma-separated list, for a [rotation](https://github.com/hookdeck/webhook-skills/blob/main/skills/isnap-webhooks/references/setup.md#how-do-i-rotate-the-signing-secret): iSnap signs with the new secret only, immediately.

## Local Development

```bash
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 isnap --path /webhooks/isnap

```

Create a subscription whose `url` is the printed URL (it must be public HTTPS), then use the signed `webhook.test` probe or a real event to exercise the handler.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/isnap-webhooks/references/overview.md) - Event catalog, envelope and subscription states
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/isnap-webhooks/references/setup.md) - Create a subscription, filters, rotate the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/isnap-webhooks/references/verification.md) - Library and manual verification, test vector, gotchas, debugging