Gareth Wilson Gareth Wilson

Guide to iSnap Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only, POST /v1/webhooks with an isnap_live_ key (trial keys can't)
FiltersEvent types, family wildcards (line.*) or *, plus optional line_ids
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
SecretPer subscription, whsec_ plus 48 bytes in base64, returned once
BodyJSON
Event routingBody field event_type
Response window2xx within 10 seconds, no redirects
RetriesUp to 7 attempts over about 14.6 hours, each freshly signed
Kill switchA 410 response deactivates the subscription
OrderingNot guaranteed
Source IPsNone 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:

FieldMeaning
api_versionAlways v1 (not the API's version number)
event_idevt_ plus 25 characters. Same on every attempt, replay and subscription, and equal to the webhook-id header. The dedupe key
event_typeThe event type, for example message.received. This is what handlers switch on. The envelope has no id or type field
created_atWhen iSnap recorded the event (ISO 8601 UTC), not when the change happened; that time is inside data
trace_id32 hex characters. Don't use it to match an event to a request
dataFields 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:

FamilyEvents
messagemessage.queued, message.scheduled, message.sent, message.delivered, message.read, message.failed, message.received, message.cancelled, message.fallback_triggered
reactionreaction.added, reaction.received, reaction.removed
typing_indicatortyping_indicator.started, typing_indicator.stopped (never sent for iMessage lines)
lineline.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
trialtrial.linked, trial.dormant, trial.reactivated, trial.bind_conflict, trial.revoked
pre_orderpre_order.fulfilled, pre_order.cancelled
macos_sessionmacos_session.rented, macos_session.released
bindingbinding.released
webhookwebhook.test (probe; never sent through an event filter)

These are the ones most integrations start with:

EventTriggered WhenCommon Use Cases
message.receivedAn inbound message is storedInbox, auto-replies, CRM sync
message.delivered / message.readThe device reports delivery, or the recipient readsStatus tracking
message.failedThe device reports a failed send, or iSnap fails a waiting messageAlerting, retry logic
message.fallback_triggeredA message went out over SMS or RCS instead of iMessageCost and channel tracking
line.offlineA BYOD line's device has not reported for 5 minutes, or another line's outage reaches 15 minutesPaging, failover
line.quota_warning / line.quota_exceededA send brings a sending limit to 80% / 100%Throttling
line.apple_id_flaggedApple disabled or restricted the line's Apple IDIncident 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.*"]
  }'
FieldRequiredValue
urlYesAbsolute https:// URL on a public host (loopback, private and reserved addresses are refused, and the host is re-checked before every attempt)
eventsYesAt least one entry: an exact type (message.received), a family wildcard (line.*) or *. There is no default filter
line_idsNoLine 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 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. 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 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 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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.