Webhooks
Webhooks let an external system react to what happens in your org - a file changed, a comment was posted, a branch was created - without polling. Swarmfile sends a signed HTTP POST to a URL you control every time something you've subscribed to happens. There are two independent kinds: an org-wide webhook, managed by an owner and shared by the whole org, and a personal webhook, a private notification channel for just your own account. Both use the same signing and delivery mechanics, described once below.
It's free and self-serve on every plan, Starter through Enterprise - there's no price gate and no sales conversation needed to turn it on.
How a delivery works#
Every delivery is a single POST request with a JSON body:
{
"apiVersion": 1,
"id": "evt_...",
"kind": "file_created",
"data": { }
}
apiVersionis a schema-version tag for the envelope shape itself (currently1). It's separate fromkind- new event kinds get added over time without bumping this number; it only changes if the envelope's own top-level shape ever does.idis a stable, unique id for this event.kindnames what happened - see Event kinds below.datais the event's own payload, shaped differently per kind.
Two headers travel with every request:
Swarmfile-Event- the same value as the body'skind, so you can route on the header without parsing JSON first.Swarmfile-Signature-t=<unix timestamp>,v1=<hex-encoded HMAC-SHA256>, the same convention Stripe uses for its own outbound webhooks. Verify it before trusting anything in the body.
Verifying the signature#
Compute an HMAC-SHA256 over the string <timestamp>.<raw request body> using your webhook's signing secret, and compare it (in constant time) to the v1 value from the header. Use the raw, unparsed request body for this - not a re-serialized version of the parsed JSON, which can differ in whitespace or key order and would make the signature not match.
expected = hex(hmac_sha256(secret, `${timestamp}.${rawBody}`))
We also recommend rejecting a request if t is too far in the past (five minutes is a reasonable window) - this protects against a captured request being replayed later.
Node.js#
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySwarmfileSignature(secret, rawBody, signatureHeader) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const givenBuf = Buffer.from(parts.v1, "hex");
return expectedBuf.length === givenBuf.length && timingSafeEqual(expectedBuf, givenBuf);
}
// Express example - mount with a raw-body parser so `req.body` is the
// exact bytes Swarmfile sent. Re-parsing and re-serializing the JSON
// before verifying is the single most common way to make this fail.
app.post("/webhooks/swarmfile", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("Swarmfile-Signature");
if (!signature || !verifySwarmfileSignature(process.env.WEBHOOK_SECRET, req.body, signature)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event.kind / event.data
res.status(200).end();
});
Python#
import hashlib
import hmac
def verify_swarmfile_signature(secret: str, raw_body: bytes, signature_header: str) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
payload = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
# Flask example - request.data is the raw, unparsed body.
@app.route("/webhooks/swarmfile", methods=["POST"])
def swarmfile_webhook():
signature = request.headers.get("Swarmfile-Signature", "")
if not verify_swarmfile_signature(WEBHOOK_SECRET, request.data, signature):
return "invalid signature", 401
event = request.get_json()
# ... handle event["kind"] / event["data"]
return "", 200
Manual verification (curl / openssl)#
Useful for confirming your receiver's math against a real delivery while debugging, without writing any code - save the raw request body Swarmfile sent to body.json, then:
TIMESTAMP="1700000000" # the "t" value from the Swarmfile-Signature header
SECRET="whsec_..." # your webhook's signing secret
printf '%s.' "$TIMESTAMP" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" -hex
Compare the resulting hex digest to the v1 value from the header - they should match exactly.
Your endpoint must be reachable on the public internet. Localhost, private network ranges (10.x, 172.16-31.x, 192.168.x), and cloud metadata addresses (169.254.169.254 and similar) are rejected at creation and re-checked immediately before every delivery, so pointing a webhook at internal infrastructure never works - this is a deliberate security boundary, not a bug. Your endpoint must also respond directly: HTTP redirects are not followed.
Each delivery attempt times out after 10 seconds. Respond with any 2xx status to acknowledge success; anything else counts as a failure for that attempt.
Org-wide webhooks#
Manage these from Settings → Webhooks. Only owners can create, edit, or remove them - a webhook is a data-egress control, the same trust tier as an API key or an SSO configuration, not a viewing convenience open to every member.
Automating webhook management#
A project-scoped API key (see Runner (Headless CI)) cannot manage webhooks, or reach any other org-wide admin surface (org settings, ACLs, directory sync) - that's deliberate: an API key is confined to one project and is never treated as an owner, no matter who minted it.
If you need to manage webhooks from a script rather than the dashboard - provisioning one as part of an infra pipeline, say - mint a personal access token instead, from Settings → Access Tokens. Unlike an API key, a personal access token isn't a separate service identity: it acts as you, with your own live role, so it can do anything your own account can do here and nothing more. It stops working the moment your own access does (role change, removal from the org), with no separate cleanup needed. It's a general-purpose credential, not specific to webhooks - the same token also works against any other route your role can reach.
Creating one#
You need an endpoint URL. Everything else is optional:
- Description - a free-text label to tell webhooks apart in the list.
- Event kinds - leave this unset to receive every kind of event, or pick specific ones (file changes, comments, branches and tags, sharing/ACL changes, unlocks, RFIs, merge requests, uploads) to only receive what you actually care about.
- Custom headers - up to 5 extra
name: valuepairs sent on every delivery, alongside the standardSwarmfile-Signature/Swarmfile-Eventpair. The most common use: anAuthorizationheader carrying a bearer token your own receiver's gateway expects, so it can gate on that in addition to (or instead of) verifying the signature.
Once created, the signing secret is shown exactly once - copy it immediately. Swarmfile never displays it again; if you lose it, rotate to a new one (below).
Custom headers#
A header value is exactly as sensitive as the signing secret itself - it's frequently going to be a credential - so it gets the same write-once treatment: once saved, Swarmfile can show you which header names are configured (so you can tell what's set without guessing), but never the values again. Editing headers means re-entering the full set you want, not patching one value in place; leaving the editor empty when editing a webhook keeps whatever's already configured, untouched.
A handful of header names are reserved and can't be overridden: Content-Type, Content-Length, Swarmfile-Signature, Swarmfile-Event, Host, Connection, Transfer-Encoding, and Upgrade - these either carry the protocol's own meaning or aren't things a webhook delivery can meaningfully override.
Delivery timing#
Deliveries are dispatched on an hourly tick, not in real time - expect events to arrive within about an hour of happening, not within seconds. If your integration needs a tighter latency than that, this isn't yet the right mechanism for it.
A failed delivery is retried automatically with increasing delays (roughly 30 seconds, 2 minutes, 10 minutes, 30 minutes, then 1 hour) for up to 6 attempts total before it's given up on.
Auto-disable#
If 5 events in a row each exhaust all of their retry attempts, the webhook is automatically disabled - Swarmfile stops trying to deliver to it rather than silently failing forever. You'll get a notification (bell, email, and your own personal webhook if you have one configured) when this happens. Fix whatever was wrong with your endpoint, then click Re-enable - delivery resumes from exactly where it left off, catching up on everything that happened while it was disabled, rather than skipping that window.
Editing, rotating, and removing#
- Edit changes the URL, description, or event-kind filter without losing the webhook's id, delivery history, or audit trail.
- Rotate secret mints a new signing secret for an existing webhook. This is a hard cutover - the old secret stops verifying the instant you rotate, with no overlap window. Update your receiver's stored secret promptly; deliveries sent before you do will look unsigned to it.
- Delete removes the webhook and its delivery history permanently.
Testing and retrying#
- Send test fires one synthetic event at your endpoint immediately, signed with the real secret, so you can confirm your receiver and signature verification actually work without waiting for a real event. It shows up in the delivery log as kind
webhook_test, but never counts toward auto-disable. - Retry re-sends one specific failed delivery from the log, using the exact payload that attempt originally sent. It's only available for deliveries recorded after this feature shipped - an older delivery's original payload isn't retained, since activity events themselves are derived on demand rather than stored permanently.
Both actions have a short cooldown per webhook (a few seconds) to stop a rapid click (or a script) from hammering your endpoint faster than intended.
Limits#
An org can have at most 20 webhooks. Every create, edit, delete, rotate, and re-enable is recorded in the org's Activity feed under the "Security" filter, so there's an audit trail of who changed what and when.
Personal webhooks#
A personal webhook is a private notification channel for your own account, alongside the bell and email - independent of any org-wide webhook an owner may have set up. Configure it under Settings → Account (the Personal webhook panel): a URL, an on/off toggle, and the same per-kind mute controls email already has (see Notifications & Inbox) - you can send comment mentions to your webhook while muting the noisier "file changed" kind, for example.
It uses the exact same envelope, headers, and signature scheme described above, with its own independently-generated secret (Send test and Rotate secret work the same way as the org-wide version).
The one real difference: a personal webhook is fire-and-forget. There's no delivery log, no automatic retry, and no auto-disable - it's held to the same bar as email, not the fuller governance treatment an org-wide webhook gets. If your endpoint is down when an event fires, that event is simply not redelivered later.
Clearing the URL turns it off. It's also cleared automatically if you're removed from the org, so a departed member's endpoint doesn't keep receiving signed events after they've lost access.
Event kinds#
Event kinds match the org Activity feed's own categories - file changes, commits, sharing/ACL changes, branches and tags, comments, unlocks, uploads, RFIs, and merge requests, plus a few webhook-governance-specific kinds. The exact, current list is what you see in the event-kind picker when creating or editing a webhook - that list is generated from the same source the Activity feed itself uses, so it can never drift out of date the way a hardcoded list on this page eventually would.
Merge request events#
mr_created, mr_reviewed, mr_merged, mr_closed, mr_reopened, mr_assigned, and mr_review_requested fire as a merge request moves through review. Every one of these carries merge_request_id, mr_number, mr_title, mr_status, project_id, and by_user_id in data. mr_reviewed additionally carries verdict, one of "approved", "changes_requested", or the non-blocking "commented" - so you can route on the outcome of a review without a follow-up API call. mr_assigned additionally carries assigned_principal_id and assigned_principal_name (the user or group that was assigned to land it); mr_review_requested carries the same pair under requested_reviewer_principal_id/requested_reviewer_principal_name (the user or group whose review was requested - distinct from being assigned to land it).
Every one of these also carries assignee_names, reviewer_names, and label_names - the merge request's current assignees, requested reviewers, and labels at the moment the event fired (all plain string arrays, empty if none), not just what changed. A "someone approved this" webhook consumer can tell who's on the hook for it without a follow-up API call.
A few fields are best-effort and can be absent: source_branch_name/target_branch_name are null if the branch itself has since been deleted past recovery, and url (a deep link straight to the merge request's page) is only present when the hub deployment has a site URL configured - an Enterprise on-prem deployment that hasn't set one won't include it.
mr_created does not fire while a merge request is a draft - on the theory that a draft isn't something you want a webhook waking anyone up for yet. That suppression is permanent, not deferred: marking a draft ready (mr ready) does not retroactively fire the mr_created a subscriber missed at creation time, so a draft-opened merge request may reach merged/closed with no mr_created ever delivered for it at all. mr_assigned and a manually-triggered mr_review_requested (someone explicitly requesting a review by hand) are unaffected and fire normally even on a draft - those are the events to rely on if you need a webhook signal that a specific draft exists. The one exception is mr_review_requested fired automatically by a branch-protection auto-reviewer at creation time: that one IS suppressed on a draft, same reasoning as mr_created - the reviewer is still attached, the webhook just doesn't fire for it until something later (marking the MR ready, then a subsequent manual action) generates a notifying event.
Where to go next#
- Notifications & Inbox - the bell, email, and quiet hours, which a personal webhook sits alongside.
- Operations - the org-wide Activity feed a webhook's own governance actions appear in.
- Permissions - how ACLs shape what an org member can see and do, for context on why webhook management is owner-only.