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.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)

HeaderValue
webhook-idThe event's event_id (evt_...), the same on every attempt and replay. Your idempotency key
webhook-timestampUnix seconds when this attempt was sent; changes on every attempt
webhook-signaturev1,<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

EventFired when
message.receivedAn inbound message is stored
message.queued / message.scheduledA message is accepted for sending, or accepted and held
message.sentThe line's device confirms it sent a message
message.delivered / message.readA message is delivered, or read by the recipient
message.failedA send fails
message.cancelledYou cancel a queued or held message
message.fallback_triggeredA message went out over SMS or RCS instead of iMessage
reaction.added / reaction.received / reaction.removedA reaction you added is confirmed, a contact reacts, or a reaction is removed
typing_indicator.started / typing_indicator.stoppedA contact starts or stops typing (never on iMessage lines)
line.connected / line.disconnected / line.offline / line.degradedLine connectivity changes
line.apple_id_flaggedApple disabled or restricted the line's Apple ID
line.quota_warning / line.quota_exceededA sending limit reaches 80% / 100%
line.capability_changed / line.terminatedA BYOD capability changes, or a line is terminated
line.variant_changed / line.variant_scheduled / line.variant_unscheduledRented line inbound-only/two-way changes
trial.linked / trial.dormant / trial.reactivated / trial.bind_conflict / trial.revokedTrial lifecycle
pre_order.fulfilled / pre_order.cancelledA pre-order is handed over or ends early
macos_session.rented / macos_session.releasedA hosted macOS session is assigned or released
binding.releasedA shared-line binding with one contact ends
webhook.testA 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 12, 2026

View on GitHub →