Agent skill
iSnap Webhooks Skill
Receive and verify iSnap webhooks, which follow the Standard Webhooks specification. Use when setting up an iSnap webhook endpoint, getting the whsec_ signing secret from POST /v1/webhooks, debugging webhook-id / webhook-timestamp / webhook-signature verification, or handling iMessage and SMS line events like message.received, message.delivered, message.failed, reaction.received and line.offline.
Install this skill
npx skills add hookdeck/webhook-skills --skill isnap-webhooks
iSnap 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.failedorline.offlineevents? - 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):
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, 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/, examples/nextjs/, 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):
{
"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_ | 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.
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
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: iSnap signs with the new secret only, immediately.
Local Development
# 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 - Event catalog, envelope and subscription states
- references/setup.md - Create a subscription, filters, rotate the secret
- references/verification.md - Library and manual verification, test vector, gotchas, debugging