Webhooks
Snag sends two kinds of webhook:
| Kind | Sent when | Signed | Answer used |
|---|---|---|---|
| Capture webhook | Every capture routed to the Send to a URL connection | Shared secret header (optional) | No: only the status counts |
| Command webhook | The user says a command whose action is a webhook | Standard 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.
| Setting | Meaning |
|---|---|
| URL | Where captures go. https, or http on a local network |
| Secret | Optional. 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"
}| Field | Present | Meaning |
|---|---|---|
id | Always | The delivery key, as in Idempotency-Key. Use it to drop duplicates |
capture_id | Always | The capture's id |
kind | Always | voice, note or task |
source | Always | snag |
recorded_at | Always | When recording or typing started (ISO 8601, UTC), however late it is sent |
locale | Always | e.g. en_GB |
text | Always | The transcript, or the typed text |
actions | Always | To-dos found in a voice capture: index, title, quote (the words it came from). Often empty |
duration_seconds | Voice | Length, to one decimal |
speech_detected | Voice | false when no speech was heard |
transcript_engine | Voice, when known | analyzer or recognizer (on the iPhone), whisper (your own service) |
input_route | Voice, when known | The microphone route at the start: MicrophoneBuiltIn, BluetoothHFP, CarAudio |
task | Task | name, notes, assign_to_me, and due_on (yyyy-MM-dd) and project (id, name) when set |
attachments_not_sent | Note with files | The file names that were not sent |
Responses
Snag reads only the status. The body is ignored.
| Status | Result |
|---|---|
| 2xx | Delivered |
| 401, 403 | Held: the webhook stops receiving until the secret or URL is fixed. Other connections carry on |
| 408, 409, 423, 425, 5xx, no connection | Retried later |
| 429 | Retried; 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"}| Header | Meaning |
|---|---|
webhook-id | msg_ and 32 hex characters, new for every call |
webhook-timestamp | Unix seconds when the call was made |
webhook-signature | Present when the command has a signing secret. See Verifying the signature |
Idempotency-Key | The same as webhook-id |
| Your header | When 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 field | Meaning |
|---|---|
type | Always snag.command |
id | The same as webhook-id |
command | The command's id |
slots | Each slot's words as heard, e.g. {"miles": "3"}. Always strings |
transcript | Everything the user said |
user | The name set in Control settings (user). Default snag; no email is sent |
timestamp | ISO 8601, UTC |
context | driving, 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 thewhsec_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 response | What Snag says |
|---|---|
| 2xx, JSON, text at the response path | That 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 read | The 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 afallbackline 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: privateasks "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):
- Set
mode: asyncon the command, or return a status URL. - Set
response.status_url_pathto where your response names an https URL, e.g.status_url_path: status_urlfor{"status_url": "https://hooks.example.com/status/abc"}. - 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. - 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
- Commands in a repo: every command field.
- Snag agent API: for agents rather than workflows.
Next: Commands in a repo