Skip to content

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 flowId and a wrong secret for a known flowId both return the exact same 404 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 .../webhook and POST .../webhook/rotate (both Bearer-authenticated, member-only).

{ "ok": false, "error": { "code": "WEBHOOK_NOT_FOUND", "message": "No webhook matches this URL." } }
HTTP status 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." } }
HTTP status 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.

  • graceSeconds is optional in the request body. Default is 86400 (24 hours). Pass 0 for immediate cutover (previousSecretExpiresAt will be null and the old secret stops working the instant you rotate).
  • secretVersion increments on every rotation (starts at 1).
  • 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-Type header
  • request size in bytes (from Content-Length, or computed if absent)
  • source IP
  • accept/reject outcome (and, if rejected, why: invalid secret, rate_limited, or flow 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:

  1. Per source IP — checked first, before the flow/secret is even looked up (throttles brute-force secret guessing).
  2. Per endpoint (i.e. per flowId) — checked once the secret has been validated.
  3. 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 } } }
HTTP status 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