Webhooks¶
Audience: Developers · Tags:
api,reference
Webhook ingress is SnapLink's inbound trigger primitive: a public, unauthenticated-by-header URL that starts a flow the instant something POSTs to it. This page is the full ingress contract. For everything else in the API, see API Reference; for building the flow a webhook triggers, see Studio API.
There is only one inbound webhook endpoint in the whole API:
POST /api/v1/webhooks/:flowId/:secret
There is no equivalent for outbound notifications — SnapLink does not offer "call my webhook when a run finishes." Webhooks in this API are inbound trigger ingestion only.
Getting your URL¶
Every flow gets its own webhook endpoint, created on first request (Bearer required — this call, unlike the ingress POST itself, is an authenticated, workspace-scoped read):
curl https://api.snaplink.cloudsolutionaccelerators.com/api/v1/flows/flow_51ac9d/webhook \
-H "Authorization: Bearer $TOKEN"
{
"ok": true,
"data": {
"flowId": "flow_51ac9d",
"secret": "9f3a2c7e1b6d4f80a1c9e2b7d4f6a801c9e2b7d4f6a801c9",
"path": "/api/v1/webhooks/flow_51ac9d/9f3a2c7e1b6d4f80a1c9e2b7d4f6a801c9e2b7d4f6a801c9"
}
}
Your full trigger URL is https://api.snaplink.cloudsolutionaccelerators.com + data.path, i.e.:
https://api.snaplink.cloudsolutionaccelerators.com/api/v1/webhooks/flow_51ac9d/9f3a2c7e1b6d4f80a1c9e2b7d4f6a801c9e2b7d4f6a801c9
The secret is a 48-character hex token (24 random bytes). Calling this endpoint again for the same flow returns the same endpoint — it doesn't rotate the secret. Only workspace members can fetch or rotate a flow's webhook secret; anyone else (including an unauthenticated caller) gets 404 FLOW_NOT_FOUND for this call, same as any other cross-workspace flow access.
The flow must be active (i.e. a draft has been promoted, see API Reference → Flows & drafts) before its webhook will accept traffic — see Rate limiting / activation below.
Authorization model: the secret in the path IS the credential¶
There is no Authorization header, no signature, no HMAC on the ingress call — the secret embedded in the URL path is the entire authorization mechanism. Treat the URL itself as a bearer credential: anyone who has it can trigger the flow.
- The secret is compared with a constant-time comparison (
timingSafeEqual), not===, specifically to resist timing attacks that could otherwise be used to brute-force it character-by-character. - Per-IP rate limiting (below) is checked before the secret is even looked up, specifically to slow down brute-force guessing of valid
(flowId, secret)pairs. - An unknown
flowIdand a wrong secret for a knownflowIdboth return the exact same404 WEBHOOK_NOT_FOUND— deliberately, so a caller probing the endpoint can't distinguish "no such flow" from "wrong secret" (no existence leak). - The secret is never logged, and it's never returned by any endpoint other than
GET .../webhookandPOST .../webhook/rotate(both Bearer-authenticated, member-only).
{ "ok": false, "error": { "code": "WEBHOOK_NOT_FOUND", "message": "No webhook matches this URL." } }
404. Note this is a distinct code from the general-purpose FLOW_NOT_FOUND used elsewhere in the API — webhook ingress has its own not-found code precisely because the anti-enumeration guarantee above only holds if unknown-flow and bad-secret produce identical responses under one dedicated code path.
The flow must be active¶
If flowId and secret both check out but the flow itself isn't in active status (e.g. it's still a draft, or has been archived), ingress is rejected with:
{ "ok": false, "error": { "code": "FLOW_NOT_ACTIVE", "message": "The flow is not active." } }
409. This attempt is still recorded as a rejected invocation (see Rejected invocations are recorded) — check GET /monitoring/webhooks if requests seem to be silently disappearing.
Rotating secrets¶
curl -X POST https://api.snaplink.cloudsolutionaccelerators.com/api/v1/flows/flow_51ac9d/webhook/rotate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"graceSeconds":3600}'
{
"ok": true,
"data": {
"flowId": "flow_51ac9d",
"secret": "b7e109fa221c48e0f6a801c9e2b7d4f6a801c9e2b7d4f6a",
"secretVersion": 2,
"path": "/api/v1/webhooks/flow_51ac9d/b7e109fa221c48e0f6a801c9e2b7d4f6a801c9e2b7d4f6a",
"previousSecretExpiresAt": "2026-07-02T15:13:00.000Z"
}
}
Rotation generates a brand-new secret immediately — the response's path is live right away. The old secret keeps working, in parallel, until previousSecretExpiresAt, so any integration still using the old URL doesn't break mid-rotation; only after that timestamp does the old secret stop matching.
graceSecondsis optional in the request body. Default is 86400 (24 hours). Pass0for immediate cutover (previousSecretExpiresAtwill benulland the old secret stops working the instant you rotate).secretVersionincrements on every rotation (starts at1).- Rotation is audited (
webhook.secret.rotated, with the flow id, new version, and grace window) — never with either secret value. - Member-only, same as fetching the URL — cross-workspace or unauthenticated calls get
404 FLOW_NOT_FOUND.
When to rotate: the secret leaked (committed to a public repo, pasted somewhere public, an integration partner you're offboarding had it), or as routine hygiene on a schedule you set. Rotating does not change flowId, so anything that only stores the flow id (not the full URL) is unaffected.
Request size cap¶
Inbound bodies are capped at 1MB by the server's global express.json({ limit: '1mb' }) — the same limit applies to every JSON body the API accepts, not something webhook-specific. A request over the cap is rejected by Express before it reaches SnapLink's webhook logic at all (standard Express body-too-large behavior, HTTP 413).
No-body-storage policy¶
SnapLink never stores the inbound request body — not the raw bytes, not a parsed/normalized version, nothing. What gets persisted per invocation is metadata only:
- HTTP method
Content-Typeheader- request size in bytes (from
Content-Length, or computed if absent) - source IP
- accept/reject outcome (and, if rejected, why:
invalid secret,rate_limited, orflow not active) - the resulting
executionRunId, if one was created
You can see exactly this metadata for a workspace via GET /monitoring/webhooks:
curl "https://api.snaplink.cloudsolutionaccelerators.com/api/v1/monitoring/webhooks?workspaceId=ws_51ac9d" \
-H "Authorization: Bearer $TOKEN"
{
"ok": true,
"data": {
"invocations": [
{
"id": "wh_1", "workspaceId": "ws_51ac9d", "flowId": "flow_51ac9d", "endpointId": "endpoint_1",
"method": "POST", "contentType": "application/json", "requestSizeBytes": 214,
"status": "accepted", "rejectionReason": null, "executionRunId": "run_7c11de",
"createdAt": "2026-07-02T15:30:00.000Z"
}
]
}
}
If your payload needs to reach the flow's actions, design around this: SnapLink records that a webhook fired, not what was in it. There is no payload templating/mapping syntax today that would let a flow forward or reshape the inbound body.
Rate limiting¶
Ingress is limited on three independent axes, checked in this order:
- Per source IP — checked first, before the flow/secret is even looked up (throttles brute-force secret guessing).
- Per endpoint (i.e. per
flowId) — checked once the secret has been validated. - Per workspace — checked at the same time as the per-endpoint limit, so a burst spread across several flows in one workspace is still caught.
A trip on any axis returns:
{ "ok": false, "error": { "code": "RATE_LIMITED", "message": "Too many requests.", "details": { "retryAfterSeconds": 12 } } }
429. When both the endpoint and workspace limits are tripped simultaneously, retryAfterSeconds is the larger (more conservative) of the two. A rate-limited request past the per-IP stage is still recorded as a rejected invocation (rejectionReason: "rate_limited") — visible in GET /monitoring/webhooks.
Back off using retryAfterSeconds, don't guess. See Rate limiting for the numeric limits and general backoff guidance across the API.
Test your webhook¶
End-to-end check: fire a request at your endpoint, then confirm a run was actually created.
BASE="https://api.snaplink.cloudsolutionaccelerators.com/api/v1"
FLOW_URL="$BASE/webhooks/flow_51ac9d/9f3a2c7e1b6d4f80a1c9e2b7d4f6a801c9e2b7d4f6a801c9"
# 1. Fire the webhook.
curl -s -X POST "$FLOW_URL" \
-H "Content-Type: application/json" \
-d '{"event":"test","source":"curl"}'
{ "ok": true, "data": { "runId": "run_7c11de", "status": "queued" } }
# 2. Confirm the run exists and see how it went (Bearer required for this call —
# the ingress POST above is the only unauthenticated call in this flow).
curl -s "$BASE/runs/run_7c11de" -H "Authorization: Bearer $TOKEN" | jq '.data.run.status'
"succeeded"
# 3. Cross-check it shows up in the workspace's webhook invocation log too.
curl -s "$BASE/monitoring/webhooks?workspaceId=ws_51ac9d" -H "Authorization: Bearer $TOKEN" \
| jq '.data.invocations[] | select(.executionRunId == "run_7c11de")'
If step 1 returns 404 WEBHOOK_NOT_FOUND, re-fetch the URL with GET /flows/:id/webhook — you likely have a stale secret (rotated, or copy-pasted wrong). If it returns 409 FLOW_NOT_ACTIVE, promote a draft first (see API Reference → Flows & drafts). If it returns 429 RATE_LIMITED, wait details.retryAfterSeconds and retry.
Related: API Reference · Studio API · Rate limiting · Webhook issues