# Guide to iSnap Webhooks: Features and Best Practices

[iSnap](https://isnap.ai) gives you iMessage and SMS lines through an API. You bring your own device, rent a line or use a shared one, then send and receive messages, reactions and typing indicators from code. Because the line lives on a real device, a lot happens that your app didn't start: a contact replies, a send fails on the handset, Apple flags the line's Apple ID, the device drops off the network. iSnap's webhooks tell you about each of those as it happens.

## What are iSnap webhooks?

An iSnap webhook is a subscription you create through the API. There is no dashboard form. Each subscription has a URL, an event filter and optionally a list of lines, and iSnap sends one compact JSON event per `POST` to every subscription that matches.

iSnap follows the Standard Webhooks specification and says so in its docs: `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, with a `whsec_` secret per subscription. It is also unusually candid that events are a fast signal, not a complete record. Some changes produce no event, and events can be lost, for example while a subscription is suspended or inactive. Reconcile against the API's resource operations for anything that has to be exact.

## iSnap webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only, `POST /v1/webhooks` with an `isnap_live_` key (trial keys can't) |
| Filters | Event types, family wildcards (`line.*`) or `*`, plus optional `line_ids` |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Secret | Per subscription, `whsec_` plus 48 bytes in base64, returned once |
| Body | JSON |
| Event routing | Body field `event_type` |
| Response window | `2xx` within 10 seconds, no redirects |
| Retries | Up to 7 attempts over about 14.6 hours, each freshly signed |
| Kill switch | A `410` response deactivates the subscription |
| Ordering | Not guaranteed |
| Source IPs | None published, and no fixed `User-Agent` |

The [iSnap webhook documentation](https://docs.isnap.ai/webhooks/overview) covers the event catalogue, delivery rules and a verification test vector.

## Events and payloads

Every delivery uses one envelope:

| Field | Meaning |
| --- | --- |
| `api_version` | Always `v1` (not the API's version number) |
| `event_id` | `evt_` plus 25 characters. Same on every attempt, replay and subscription, and equal to the `webhook-id` header. The dedupe key |
| `event_type` | The event type, for example `message.received`. This is what handlers switch on. The envelope has no `id` or `type` field |
| `created_at` | When iSnap recorded the event (ISO 8601 UTC), not when the change happened; that time is inside `data` |
| `trace_id` | 32 hex characters. Don't use it to match an event to a request |
| `data` | Fields of the event type. Always holds `metadata` (of the main resource) and `line_metadata` (of the line, when it is yours) |

Events carry personal data: message text, handles, contact names and attachment file names. Subscribe only to the types you need and keep that data out of logs.

iSnap documents 37 events in nine families:

| Family | Events |
| --- | --- |
| `message` | `message.queued`, `message.scheduled`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.received`, `message.cancelled`, `message.fallback_triggered` |
| `reaction` | `reaction.added`, `reaction.received`, `reaction.removed` |
| `typing_indicator` | `typing_indicator.started`, `typing_indicator.stopped` (never sent for iMessage lines) |
| `line` | `line.connected`, `line.disconnected`, `line.offline`, `line.degraded`, `line.apple_id_flagged`, `line.quota_warning`, `line.quota_exceeded`, `line.capability_changed`, `line.terminated`, `line.variant_changed`, `line.variant_scheduled`, `line.variant_unscheduled` |
| `trial` | `trial.linked`, `trial.dormant`, `trial.reactivated`, `trial.bind_conflict`, `trial.revoked` |
| `pre_order` | `pre_order.fulfilled`, `pre_order.cancelled` |
| `macos_session` | `macos_session.rented`, `macos_session.released` |
| `binding` | `binding.released` |
| `webhook` | `webhook.test` (probe; never sent through an event filter) |

These are the ones most integrations start with:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `message.received` | An inbound message is stored | Inbox, auto-replies, CRM sync |
| `message.delivered` / `message.read` | The device reports delivery, or the recipient reads | Status tracking |
| `message.failed` | The device reports a failed send, or iSnap fails a waiting message | Alerting, retry logic |
| `message.fallback_triggered` | A message went out over SMS or RCS instead of iMessage | Cost and channel tracking |
| `line.offline` | A BYOD line's device has not reported for 5 minutes, or another line's outage reaches 15 minutes | Paging, failover |
| `line.quota_warning` / `line.quota_exceeded` | A send brings a sending limit to 80% / 100% | Throttling |
| `line.apple_id_flagged` | Apple disabled or restricted the line's Apple ID | Incident response |

Not every line gets every `line.*` event. `line.degraded` and `line.capability_changed` are for BYOD lines only, `line.variant_*` for rented lines only, and shared lines never get `line.connected`, `line.offline` or `line.apple_id_flagged`.

This is the `binding.released` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/isnap/latest/binding.released.json), a documentation example of a shared line's binding to a contact ending:

```json
{
  "api_version": "v1",
  "event_id": "evt_LqOhtsNo1Ls99bUKw0EHCleMv",
  "event_type": "binding.released",
  "created_at": "2026-10-08T03:00:12.277Z",
  "trace_id": "e213cf8d0af9f56bcdbd14e398325cda",
  "data": {
    "line_id": "line_5Hd9Tx3Kc7Wn2Qb6Lm8Vz4Ry1",
    "contact_handle": "+14155550123",
    "outbound_number": "+14155550126",
    "released_at": "2026-10-08T03:00:12.000Z",
    "reason": "inactivity",
    "metadata": null,
    "line_metadata": null
  }
}

```

`created_at` is when iSnap recorded the event. The time the change happened is inside `data`, here `released_at`.

## Setting up iSnap webhooks

You need an iSnap account with a live API key (`isnap_live_...`) and a public HTTPS URL. Create the subscription:

```bash
curl -sS -X POST https://api.isnap.ai/v1/webhooks \
  -H "Authorization: Bearer $ISNAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.your-domain.example/isnap",
    "events": ["message.*", "line.*"]
  }'

```

| Field | Required | Value |
| --- | --- | --- |
| `url` | Yes | Absolute `https://` URL on a public host (loopback, private and reserved addresses are refused, and the host is re-checked before every attempt) |
| `events` | Yes | At least one entry: an exact type (`message.received`), a family wildcard (`line.*`) or `*`. There is no default filter |
| `line_ids` | No | Line ids to receive events for. Absent or `[]` means all lines |

The call returns `201` and the subscription is active straight away. `description` and `active` are ignored on create; set them later with `PATCH /v1/webhooks/{id}`. Subscribing to `webhook.test` or `webhook.*` has no effect, because probes bypass the filter.

The response's `secret` field holds the signing secret, and no other call returns it. Run the command somewhere its output isn't logged, and store the whole value, prefix included, in `ISNAP_WEBHOOK_SECRET`.

Two details catch people out at this step:

* `POST /v1/webhooks` ignores `Idempotency-Key`. If the response is lost and you send the request again, you get a second subscription and every event twice. Call `GET /v1/webhooks` before retrying a create.
* A delivery doesn't carry its subscription's `wh_` ID. Give each subscription its own URL, for example with a path segment, or your receiver has to try every secret.

To receive iSnap through Hookdeck, create a source with the **iSnap** type, add the secret, and use the source URL as `url`. The [Hookdeck CLI](/docs/cli) forwards it to a local receiver:

```bash
npx hookdeck-cli listen 3000 isnap --path /webhooks/isnap

```

Then send a message to the line from another phone and check that `message.received` arrives with its headers and raw body intact.

## Securing iSnap webhooks

iSnap's verification page gives the scheme and a test vector. Remove `whsec_` and base64-decode the rest, which gives 48 bytes. It is not hex and not the literal string. Compute HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw body>`, base64-encode it, and compare against each `v1,` entry in `webhook-signature`. Read the event type from the signed body, not the unsigned `X-iSnap-Event` header.

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

iSnap tells receivers to refuse a timestamp more than 300 seconds from their clock. For a direct receiver that works, because each of iSnap's own attempts carries a fresh timestamp and signature. Behind Hookdeck it doesn't: Hookdeck applies no timestamp tolerance, and its retries and replays send the original `webhook-timestamp`, so the Standard Webhooks and Svix libraries' five-minute window would reject them. The function above checks the signature only.

The receiver below stores the verified event and answers fast. Failed verification returns `401`, which iSnap retries. It must never return `410`:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// inbox.save(eventId, body) persists the raw event and resolves once it is durable.
function createIsnapReceiver(inbox) {
  const app = express();
  app.post('/webhooks/isnap', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.ISNAP_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    if (event.event_type === 'webhook.test') return res.sendStatus(200); // probe: just acknowledge
    try {
      await inbox.save(event.event_id, req.body);
    } catch {
      return res.sendStatus(503); // retried by iSnap; 410 would switch the subscription off
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Mount `express.raw` before any JSON middleware and turn off CSRF protection on this route. iSnap's own Express sample uses `express.raw({ type: 'application/json' })`; `'*/*'` also copes with an unexpected content type.

A Hookdeck iSnap source answers iSnap with `200` whatever the signature. Requests that fail are recorded as failed verification and aren't forwarded.

> **Make iSnap webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) takes every iSnap event on the first attempt, so a slow or failing receiver never pushes your subscription towards suspension.

## iSnap webhook limitations and pain points

### A `410` switches the subscription off

**The Problem:** Events stop arriving after a deploy, and `GET /v1/webhooks` shows the subscription as `active: false`.

**Why It Happens:** iSnap treats `410 Gone` as a request to stop. The subscription is deactivated at once, and events that happen while it's inactive are lost, not queued.

**Workarounds:**

* Never return `410`, including from frameworks or proxies that map a missing route to it. Use `400` for missing headers and `401` for a bad signature.
* Reactivate with `PATCH /v1/webhooks/{id}` and `"active": true`, then reconcile through the API.

**How Hookdeck Can Help:** A Hookdeck source always answers iSnap with `200`, so nothing your service returns can deactivate the subscription.

### Ten failed deliveries suspend the subscription

**The Problem:** After an outage, deliveries sit in `pending` with `next_retry_at: null` and new events don't arrive.

**Why It Happens:** When 10 consecutive deliveries use up their attempts, iSnap suspends the subscription until a `webhook.test` probe succeeds. Deliveries waiting at that point are resent with a new schedule unless they're older than 24 hours, but events that occur during the suspension are not recorded at all.

**Workarounds:**

* Watch for `failure_count` of 10 or more in `GET /v1/webhooks`, or a `webhook.test` request reaching you.
* Answer probes with `2xx` and backfill the gap from the API.

**How Hookdeck Can Help:** Hookdeck accepts each event on iSnap's first attempt and [retries](/docs/retries) your service on its own schedule, so your outage never counts against the subscription.

### Rotation has no overlap

**The Problem:** Every delivery fails verification right after a secret rotation.

**Why It Happens:** `POST /v1/webhooks/{id}/rotate-secret` takes effect immediately. From then on iSnap signs everything, including retries of older deliveries and replays, with the new secret only. The returned `old_secret_expires_at` is informational.

**Workarounds:**

* Make the receiver accept a list of secrets, rotate once (a second call replaces the secret again), and add the new secret straight away.
* Until the new secret is deployed, answer with a retryable non-`2xx`, never `410`.

**How Hookdeck Can Help:** Updating the secret on the Hookdeck source is the only change needed, and requests that failed in the gap remain in the log, where you can [retry](/docs/retries) them.

### Events don't arrive in order

**The Problem:** `message.read` reaches you before `message.delivered` for the same message.

**Why It Happens:** iSnap doesn't guarantee delivery order, and delivery is at least once.

**Workarounds:**

* Deduplicate on `event_id`, and only move a message's status forward.
* Use the timestamps inside `data`, not `created_at`, to order changes.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated `event_id` values before they reach your service.

## Best practices

### Keep message content out of logs

Message bodies, handles and contact names are personal data. Log `event_id`, `event_type` and the line ID instead.

### Page on line health events

`line.offline`, `line.apple_id_flagged` and `line.quota_exceeded` mean a line can't send. Route them to on-call rather than into the message pipeline.

### Track SMS fallback

`message.fallback_triggered` tells you a message went out over SMS or RCS instead of iMessage. Count it per line to see which lines fall back most often.

### Make processing idempotent

Every attempt, replay and subscription shares one `event_id`. Record it alongside the change it causes; our [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) covers the patterns.

## Conclusion

iSnap documents its webhooks precisely, down to a verification test vector and retry rules. The care goes into operations: a `410` or repeated failures can silence a subscription, and iSnap says events aren't a complete record.

[Hookdeck Event Gateway](https://hookdeck.com) puts a stable `200` in front of your receiver and keeps every iMessage and line event until your service has handled it. [Start free with Hookdeck](https://dashboard.hookdeck.com/signup).