Guide to iSnap Webhooks: Features and Best Practices
iSnap 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 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_ |
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_ | 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, a documentation example of a shared line's binding to a contact ending:
{
"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:
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/webhooksignoresIdempotency-Key. If the response is lost and you send the request again, you get a second subscription and every event twice. CallGET /v1/webhooksbefore 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 forwards it to a local receiver:
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.
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:
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 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. Use400for missing headers and401for 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_countof 10 or more inGET /v1/webhooks, or awebhook.testrequest reaching you. - Answer probes with
2xxand backfill the gap from the API.
How Hookdeck Can Help: Hookdeck accepts each event on iSnap's first attempt and 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, never410.
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 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, notcreated_at, to order changes.
How Hookdeck Can Help: Hookdeck's 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_ 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 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 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.