Webhooks
A webhook sends an HTTP POST with a JSON body to a URL you choose
whenever something happens in a repository. Webhooks are stored and
delivered by the repository’s spindle, so a repository needs a
spindle selected before it can have any.
Adding a webhook
Section titled “Adding a webhook”- Open the repository’s Settings → Hooks and select New webhook.
- Payload URL: the endpoint that receives the requests, for example
https://example.com/webhook. - Secret (optional): if set, every request is signed with HMAC-SHA256 so your endpoint can verify it. Leave it blank to send unsigned requests.
- Events: pick at least one (see below).
- Active: leave on to start delivering right away.
- Select Add.
Repository owners and collaborators can manage webhooks. If Settings → Hooks asks you to Authorize webhooks, sign in again: webhook access was added to the app’s permissions after your session started.
To change a webhook, select Edit. Leave New secret blank to keep the existing secret, or tick Remove the existing secret to stop signing. The toggle next to each webhook pauses and resumes it without deleting it.
Events
Section titled “Events”| Option | Event name | Sent when |
|---|---|---|
| Push events | push | A branch or tag is updated on the knot. |
| Repository renamed | repository:renamed | The repository’s name changes. |
| Pull request opened | pull_request:created | A pull request is opened. |
| Pull request resubmitted | pull_request:resubmitted | A new round is pushed to a pull request. |
| Pull request merged | pull_request:merged | A pull request is merged. |
| Pull request closed | pull_request:closed | A pull request is closed without merging. |
| Pull request reopened | pull_request:reopened | A closed pull request is reopened. |
The event name arrives in the X-Tangled-Event header. Issue events
aren’t available yet.
Receiving a delivery
Section titled “Receiving a delivery”Your endpoint should:
- Read the raw request body before parsing it. The signature covers those exact bytes.
- If you set a secret, check
X-Tangled-Signature-256and reject the request if it’s missing or wrong. - Route on
X-Tangled-Event. - Respond with a
2xxstatus within 30 seconds. Queue slow work and respond first.
Headers
Section titled “Headers”POST /webhook HTTP/1.1Content-Type: application/jsonUser-Agent: Tangled-Hook/7b320e5X-Tangled-Event: pushX-Tangled-Hook-ID: 12X-Tangled-Delivery: 5f0c1c9e-3c1d-4f43-9a55-0c2b8f1e7d21X-Tangled-Repo: did:plc:hwevmowznbiukdf6uk5dwrrq/3lzsomerkeyX-Tangled-Signature-256: sha256=9f2c…| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Tangled-Hook/<first 7 characters of the new commit> for pushes, Tangled-Hook/rename, Tangled-Hook/pull_request, or Tangled-Hook/retry for a manual retry. |
X-Tangled-Event | The event name, e.g. push or pull_request:merged. |
X-Tangled-Hook-ID | The webhook’s numeric id on the spindle. |
X-Tangled-Delivery | A UUID, new for every delivery including retries. |
X-Tangled-Repo | The repository’s full_name (see below). |
X-Tangled-Signature-256 | sha256=<hex HMAC>, only when the webhook has a secret. |
Payloads
Section titled “Payloads”The repository object
Section titled “The repository object”Every payload has a repository object:
{ "name": "some-repo", "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/3lzsomerkey", "description": "", "fork": false, "html_url": "https://knot.example.com/did:plc:hwevmowznbiukdf6uk5dwrrq/3lzsomerkey", "clone_url": "https://knot.example.com/did:plc:hwevmowznbiukdf6uk5dwrrq/3lzsomerkey", "ssh_url": "ssh://git@knot.example.com/did:plc:hwevmowznbiukdf6uk5dwrrq/3lzsomerkey", "created_at": "2025-09-15T08:57:23Z", "updated_at": "2025-09-15T08:57:23Z", "owner": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" }}| Field | Meaning |
|---|---|
full_name | <owner DID>/<record key> of the repository record. It doesn’t change on rename. |
description | Always empty in the current spindle. |
fork | Always false in the current spindle. |
html_url, clone_url, ssh_url | Built from the repository’s knot hostname, owner DID and record key. |
created_at, updated_at | When the spindle learned about the repository. Both carry the same value. |
owner.did | The repository owner’s DID. |
website, stars_count, open_issues_count | Defined but never sent by the current spindle. |
{ "ref": "refs/heads/main", "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e", "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5", "pusher": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" }, "repository": { "...": "see above" }}| Field | Meaning |
|---|---|
ref | The full ref that changed, e.g. refs/heads/main or refs/tags/v1.0.0. |
before | The ref’s previous commit. |
after | The ref’s new commit. |
pusher.did | The account the knot recorded as making the update, or the repository owner if none was recorded. |
One event is sent per updated ref, so a push of three branches produces three deliveries.
Repository renamed
Section titled “Repository renamed”{ "old_name": "some-repo", "new_name": "better-name", "repository": { "...": "see above, with the new name" }, "sender": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" }}Pull request
Section titled “Pull request”All pull_request:* events share one shape:
{ "action": "created", "pull_request": { "number": 0, "title": "add dark mode", "body": "implements dark mode as discussed in #2", "state": "open", "target_branch": "main", "source": { "branch": "dark-mode" }, "round_number": 1, "owner": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" }, "created_at": "2025-09-15T08:57:23Z" }, "repository": { "...": "see above" }, "sender": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" }}| Field | Meaning |
|---|---|
action | The event name’s suffix: created, resubmitted, merged, closed or reopened. |
pull_request.number | Always 0 in the current spindle. Don’t use it to identify the pull request. |
pull_request.title, body | From the pull request. body is empty when it has none. |
pull_request.state | merged for merged, closed for closed, otherwise open. |
pull_request.target_branch | The branch the pull request targets. |
pull_request.source | Present for branch-based and fork-based pull requests, absent for patch-based ones. branch is the source branch; repo is the source repository, only for forks. |
pull_request.round_number | How many rounds the pull request has. |
pull_request.owner.did | The pull request’s author. |
pull_request.created_at | When the pull request was opened. |
sender.did | Who performed the action: the author for created and resubmitted, the account that changed the state otherwise. |
The patch itself isn’t included. html_url, patch_url and
source.sha are defined in the payload type but not filled in by the
current spindle.
Verifying signatures
Section titled “Verifying signatures”When the webhook has a secret, X-Tangled-Signature-256 is sha256=
followed by the hex HMAC-SHA256 of the raw request body, keyed with the
secret. Compute the same HMAC over the bytes you received and compare in
constant time. Reject the request when the header is missing.
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "strings")
func verifySignature(payload []byte, header, secret string) bool { if !strings.HasPrefix(header, "sha256=") { return false } signature := strings.TrimPrefix(header, "sha256=")
mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payload) expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))}
func webhookHandler(w http.ResponseWriter, r *http.Request) { payload, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if !verifySignature(payload, r.Header.Get("X-Tangled-Signature-256"), yourSecret) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } // respond first, process asynchronously go processWebhook(r.Header.Get("X-Tangled-Event"), payload) w.WriteHeader(http.StatusOK)}A retried delivery from the Deliveries page is signed with the webhook’s current secret, so rotating the secret and retrying works.
Deliveries and retries
Section titled “Deliveries and retries”Each webhook’s Deliveries page lists recent attempts with the payload URL, the request body, the response status and body, and whether it succeeded. The spindle keeps the last 50 deliveries per webhook, with up to 10 KiB of each response body. Select Retry to send a delivery again; the retry is signed with the webhook’s current secret and gets a new delivery id.
The spindle tries each delivery up to 3 times, backing off from 1 second
up to 10 seconds, when the connection fails or your endpoint returns a
5xx status. A 4xx response is recorded as a failure and not retried,
so only return 4xx when the same request can never succeed. Deliveries
are not retried later beyond those 3 attempts.
Requests are sent from the spindle’s network, not from the web app. The spindle refuses to deliver to private and internal addresses and doesn’t follow redirects, so the payload URL must be a public address that answers directly. If many events arrive at once and the spindle’s delivery queue is full, the event is recorded as a failed delivery instead of being sent; retry it from the Deliveries page.
Example: Discord notification
Section titled “Example: Discord notification”app.post( "/webhook", express.raw({ type: "application/json" }), async (req, res) => { // verify req.body (a Buffer) against X-Tangled-Signature-256 first const payload = JSON.parse(req.body); res.status(200).send("OK");
if (req.get("X-Tangled-Event") !== "push") return; await fetch("https://discord.com/api/webhooks/...", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `${payload.pusher.did} pushed to ${payload.ref} in ${payload.repository.name}`, }), }); },);