Webhooks
Introduction
Webhooks are HTTPS destinations that receive a signed POST when an async job
reaches a terminal status (completed, failed, or expired). Create them
under Webhooks, then attach one or more to a source on Sources → Concurrency.
A source needs at least one active destination to run async. With none attached, the source serves requests synchronously instead. Callers can also poll the job URL with source auth; see Async for statuses and TTL.
Async admission is plan-gated (Team and Business). See Billing.
Create a webhook
- Open Webhooks → Add webhook.
- Name it and set an
https://URL. - Copy the signing secret. It is shown once on create (and again on rotate).
Private, loopback, and link-local destinations are rejected.
An assistant on the MCP server can do the same with
create_webhook (optional source_id to attach and enable async). Scripts
use POST /api/v1/webhooks on the Control API. Both
return the signing secret once. They cannot point a destination at loopback
or private URLs.
Attach to a source
On the source Concurrency tab, enable async and select webhook destinations. Save webhook attachments separately from the rest of the concurrency form.
The same webhook can attach to many sources. Edit the URL once and every attached source picks it up within a few seconds.
From MCP, set_source_webhooks replaces the destinations on a granted source and
turns async on if it is still off. From the Control API, PUT /api/v1/sources/{subdomain}/webhooks does the same. The dashboard does not
turn async on by itself when you only change attachments.
Details and delivery history
Open a webhook from the list to see status, success and failure totals, consecutive failures, attached sources, and recent delivery attempts (time, job id, source, result, HTTP status or error, attempt, latency).
Success and timeouts
A delivery succeeds when your receiver returns HTTP 2xx within 5 seconds. Timeouts, TLS errors, and non-2xx responses count as failures.
A failed delivery is retried with backoff (5 s, 10 s, 20 s, … up to 5 minutes) for up to 8 attempts per job (fewer when the job itself waited through retries before it ran). Retrying a webhook never re-runs the job. After the last attempt the destination is marked as given up for that job.
Auto-deactivate
After 8 consecutive failures, swarmvia deactivates the webhook. Deliveries stop until you fix the receiver and click Re-enable on the details page. Success resets the failure streak. Deactivated endpoints stay attached to sources but are omitted from delivery until re-enabled.
If deactivation leaves a source with no active destination, that source goes back to serving requests synchronously until you re-enable one.
Verify signatures
Each delivery is a POST with JSON body and these headers:
| Header | Meaning |
|---|---|
X-Swarmvia-Signature |
Hex HMAC-SHA256 of the canonical string |
X-Swarmvia-Timestamp |
Unix seconds |
X-Swarmvia-Job-Id |
Job id (also the idempotency key) |
X-Swarmvia-Webhook-Id |
Destination id |
X-Swarmvia-Event |
Event type (same as type in the body) |
The same values are also sent under the previous X-Nthpool-* names.
Canonical string (newline-separated):
TIMESTAMP
JOB_ID
SHA256_HEX(RAW_BODY)Recompute the HMAC with your signing secret and compare with a constant-time equals. Reject if the timestamp is too far from your clock.
Deliveries are at-least-once. Key idempotency on id / X-Swarmvia-Job-Id.
Payload
The body is a versioned event envelope. Results are inlined when they fit the
256 KiB UTF-8 body cap. When the result is too large (or not UTF-8 text),
detail omits the payload and sets resultUrl instead so you can fetch it
with source auth. truncated: true means the stored result was already incomplete;
size overflow alone does not set it.
{
"apiVersion": "1",
"type": "async.job.completed",
"id": "job_…",
"pool": "your-source.swarmvia.cloud",
"reason": "requested",
"createdAt": 1710000000000,
"completedAt": 1710000123000,
"detail": {
"kind": "http",
"status": 200,
"headers": { "content-type": "application/json" },
"body": "{\"ok\":true}"
}
}Large result (reference only):
{
"detail": {
"kind": "http",
"status": 200,
"headers": { "content-type": "application/json" },
"resultUrl": "https://api.swarmvia.cloud/pools/your-source/jobs/job_…"
}
}| Field | Meaning |
|---|---|
apiVersion |
Envelope version (1) |
type |
async.job.completed, async.job.failed, or async.job.expired |
id |
Job id |
pool |
Canonical hostname of the source (the key keeps its original name) |
reason |
Why the job was accepted: requested (header), busy (open wait), or wait_timeout |
createdAt / completedAt |
Unix milliseconds |
detail |
Result payload; omitted when there is nothing to report (for example some expired jobs) |
detail.kind is http for API sources and database for database sources.
Prefer the inlined payload (body, columns/rows, or results for a
queries job) when present; when resultUrl is set, fetch that URL with the
same source auth you use for poll. A database batch inlines every statement
the same way a small single query inlines rows — the 256 KiB cap is the
only reason to fall back.
Switch on type and detail.kind; do not sniff fields to learn which source
backend produced the job.
Rotate a secret
Use Rotate on the Webhooks page or details page. Update your receiver before the next delivery. Attached sources are re-pushed with the new secret.
Next steps
- Async (headers, poll, modes)
- Concurrency
- Long NetSuite writes without client timeouts