Snag for developers

Webhooks

Snag sends two kinds of webhook:

KindSent whenSignedAnswer used
Capture webhookEvery capture routed to the Send to a URL connectionShared secret header (optional)No: only the status counts
Command webhookThe user says a command whose action is a webhookStandard Webhooks HMAC (optional)Yes: Snag says the reply

Capture webhook

A destination: each capture is posted as JSON to your URL (n8n, Make, Zapier, your own server). Capturing to a webhook is free. Set it up in Settings > Connections > Add a connection > Send to a URL (in the Advanced group, shown once Snag Control is on). See Destinations.

SettingMeaning
URLWhere captures go. https, or http on a local network
SecretOptional. Sent as-is in the X-Snag-Secret header. Stored in the Keychain

Request

POST https://hooks.example.com/snag
Content-Type: application/json
Accept: application/json
User-Agent: Snag
Idempotency-Key: <uuid>
X-Snag-Secret: <secret>

Idempotency-Key is the capture's id, the same on every retry of that capture. X-Snag-Secret is sent only when a secret is set. No audio and no files are sent.

Body

Keys are sorted, so the same capture always gives the same bytes.

A voice capture:

{
  "actions": [
    {"index": 0, "title": "Call the printer about the proofs", "quote": "call the printer about the proofs tomorrow"}
  ],
  "capture_id": "5d0c2f1e-7b3a-4c8e-9f21-6a4b8d0e3c77",
  "duration_seconds": 12.4,
  "id": "5d0c2f1e-7b3a-4c8e-9f21-6a4b8d0e3c77",
  "input_route": "BluetoothHFP",
  "kind": "voice",
  "locale": "en_GB",
  "recorded_at": "2026-10-16T08:30:00Z",
  "source": "snag",
  "speech_detected": true,
  "text": "Call the printer about the proofs tomorrow.",
  "transcript_engine": "analyzer"
}

A task:

{
  "actions": [],
  "capture_id": "9a1e4b2c-0d3f-4e6a-8b7c-1f2e3d4c5b6a",
  "id": "9a1e4b2c-0d3f-4e6a-8b7c-1f2e3d4c5b6a",
  "kind": "task",
  "locale": "en_GB",
  "recorded_at": "2026-10-16T09:05:00Z",
  "source": "snag",
  "task": {
    "assign_to_me": true,
    "due_on": "2026-10-17",
    "name": "Book the van",
    "notes": "For the Saturday delivery",
    "project": {"id": "p1", "name": "Shop"}
  },
  "text": "Book the van"
}
FieldPresentMeaning
idAlwaysThe delivery key, as in Idempotency-Key. Use it to drop duplicates
capture_idAlwaysThe capture's id
kindAlwaysvoice, note or task
sourceAlwayssnag
recorded_atAlwaysWhen recording or typing started (ISO 8601, UTC), however late it is sent
localeAlwayse.g. en_GB
textAlwaysThe transcript, or the typed text
actionsAlwaysTo-dos found in a voice capture: index, title, quote (the words it came from). Often empty
duration_secondsVoiceLength, to one decimal
speech_detectedVoicefalse when no speech was heard
transcript_engineVoice, when knownanalyzer or recognizer (on the iPhone), whisper (your own service)
input_routeVoice, when knownThe microphone route at the start: MicrophoneBuiltIn, BluetoothHFP, CarAudio
taskTaskname, notes, assign_to_me, and due_on (yyyy-MM-dd) and project (id, name) when set
attachments_not_sentNote with filesThe file names that were not sent

Responses

Snag reads only the status. The body is ignored.

StatusResult
2xxDelivered
401, 403Held: the webhook stops receiving until the secret or URL is fixed. Other connections carry on
408, 409, 423, 425, 5xx, no connectionRetried later
429Retried; a Retry-After in seconds is honoured
Anything else (400, 404, 410, 422, ...)Refused for good: "The webhook refused the capture (HTTP 404)."

Timeout 60 s. Retries back off from 30 s, doubling each time, up to 30 minutes between tries. Captures wait in the queue on the phone while offline.

Checking the secret

Compare X-Snag-Secret with your stored secret in constant time, and refuse with 401 if it does not match. There is no signature on capture webhooks: keep the URL private and use https.

Command webhooks

A command maps a spoken phrase ("log {miles} miles") to a webhook. Snag posts the match, then says your reply. Voice commands are part of Snag Control, a Pro feature. See Voice commands for setting them up and Commands in a repo for the file format.

Request

POST https://n8n.example.com/webhook/snag-miles
Content-Type: application/json
Accept: application/json
User-Agent: Snag
Idempotency-Key: <webhook-id>
webhook-id: msg_2f6c0e9a4b1d4e7f8a3c5b2d1e0f9a8b
webhook-timestamp: 1792138129
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
X-Api-Key: <header value>
{"command":"miles","context":{"driving":true,"locale":"en_GB","source":"carplay"},"id":"msg_2f6c0e9a4b1d4e7f8a3c5b2d1e0f9a8b","slots":{"miles":"3"},"timestamp":"2026-10-16T08:30:00Z","transcript":"log 3 miles","type":"snag.command","user":"snag"}
HeaderMeaning
webhook-idmsg_ and 32 hex characters, new for every call
webhook-timestampUnix seconds when the call was made
webhook-signaturePresent when the command has a signing secret. See Verifying the signature
Idempotency-KeyThe same as webhook-id
Your headerWhen the command sets header_auth (a header name, e.g. X-Api-Key), that header carries the value saved on the phone in Settings > Advanced > Use my own infrastructure > Direct endpoints > Keys, under the name header.<command id>
Body fieldMeaning
typeAlways snag.command
idThe same as webhook-id
commandThe command's id
slotsEach slot's words as heard, e.g. {"miles": "3"}. Always strings
transcriptEverything the user said
userThe name set in Control settings (user). Default snag; no email is sent
timestampISO 8601, UTC
contextdriving, source (carplay, siri, phone), locale

The body is compact JSON with sorted keys. Verify the signature against the raw bytes, not a re-serialised copy.

Verifying the signature

The scheme is Standard Webhooks:

  • The secret is whsec_ followed by base64 bytes. The key is those bytes, base64-decoded. A secret without the whsec_ prefix is used as its raw UTF-8 bytes.
  • The signed content is <webhook-id>.<webhook-timestamp>.<raw body>.
  • The signature is HMAC-SHA256 over that content, base64-encoded, sent as v1,<signature>. The header may hold several space-separated signatures; accept if any matches.
  • Refuse a timestamp more than 5 minutes from your clock.

A command added in the app gets its own secret (whsec_ and 24 random bytes): copy it into your workflow when Snag shows it. For a command from a repo, make one with echo "whsec_$(openssl rand -base64 24)", put it in your workflow, and paste it on the command in Snag (Signing secret). Until a secret is saved, Snag sends calls unsigned.

Node:

const crypto = require('crypto');

function verify(headers, rawBody, secret) {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const expected = 'v1,' + crypto.createHmac('sha256', key)
    .update(`${id}.${ts}.${rawBody}`).digest('base64');
  const given = (headers['webhook-signature'] || '').split(' ');
  const ok = given.some(s => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  return ok && fresh;
}

Python:

import base64, hashlib, hmac, time

def verify(headers, raw_body: bytes, secret: str) -> bool:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    msg = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
    expected = "v1," + base64.b64encode(hmac.new(key, msg, hashlib.sha256).digest()).decode()
    given = headers.get("webhook-signature", "").split(" ")
    fresh = abs(time.time() - int(headers["webhook-timestamp"])) <= 300
    return fresh and any(hmac.compare_digest(s, expected) for s in given)

In n8n, turn on the Webhook node's Raw Body option so the bytes match what Snag signed.

The reply

A command in sync mode (the default) waits up to 20 s for the reply; async waits up to 15 s for the acknowledgement.

Your responseWhat Snag says
2xx, JSON, text at the response pathThat text. The path defaults to spoken: {"spoken": "Logged 3 miles."}. With the default path, a say field is read when there is no spoken
2xx, a bare text body (not JSON, not HTML)The text as is
2xx, nothing at the path, a status URL at status_url_path"Sent." and the command stays In flight
2xx, nothing to readThe command's fallback, else "Sent."
2xx, mode: async"Sent." and, with a status URL in the response, the command stays In flight
401, 403"That workflow refused the key."
Other status"That workflow failed."
No connection, timeout"Couldn't reach that workflow."
URL not allowed"That command's URL is refused."
  • The path is dotted, with array indexes as numbers: data.0.text. Numbers and booleans are read as text.
  • {slot} and {transcript} in a fallback line are filled in: "Logged {miles} miles."
  • The reply is spoken in chunks of up to 25 words, with "More, or park it?" between them. A command marked sensitivity: private asks "Say it out loud?" first.
  • Command webhooks are not retried. A failure is said once.

Answering later

For a workflow that answers after it has replied (Zapier's Catch Hook, n8n set to respond immediately):

  1. Set mode: async on the command, or return a status URL.
  2. Set response.status_url_path to where your response names an https URL, e.g. status_url_path: status_url for {"status_url": "https://hooks.example.com/status/abc"}.
  3. Snag polls that URL with GET (Accept: application/json, no auth headers, 20 s timeout) on every refresh while it is open: on opening, every 60 s, and on demand.
  4. When the response path has text, it is waiting to be heard. {"state": "failed"} fails it. Anything else is still working.

Without a status URL, let the workflow post to the user's ntfy topic (Settings > Advanced > ntfy) when it has finished.

See also

Next: Commands in a repo