Skip to content

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.

  1. Open the repository’s Settings → Hooks and select New webhook.
  2. Payload URL: the endpoint that receives the requests, for example https://example.com/webhook.
  3. Secret (optional): if set, every request is signed with HMAC-SHA256 so your endpoint can verify it. Leave it blank to send unsigned requests.
  4. Events: pick at least one (see below).
  5. Active: leave on to start delivering right away.
  6. 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.

OptionEvent nameSent when
Push eventspushA branch or tag is updated on the knot.
Repository renamedrepository:renamedThe repository’s name changes.
Pull request openedpull_request:createdA pull request is opened.
Pull request resubmittedpull_request:resubmittedA new round is pushed to a pull request.
Pull request mergedpull_request:mergedA pull request is merged.
Pull request closedpull_request:closedA pull request is closed without merging.
Pull request reopenedpull_request:reopenedA closed pull request is reopened.

The event name arrives in the X-Tangled-Event header. Issue events aren’t available yet.

Your endpoint should:

  1. Read the raw request body before parsing it. The signature covers those exact bytes.
  2. If you set a secret, check X-Tangled-Signature-256 and reject the request if it’s missing or wrong.
  3. Route on X-Tangled-Event.
  4. Respond with a 2xx status within 30 seconds. Queue slow work and respond first.
POST /webhook HTTP/1.1
Content-Type: application/json
User-Agent: Tangled-Hook/7b320e5
X-Tangled-Event: push
X-Tangled-Hook-ID: 12
X-Tangled-Delivery: 5f0c1c9e-3c1d-4f43-9a55-0c2b8f1e7d21
X-Tangled-Repo: did:plc:hwevmowznbiukdf6uk5dwrrq/3lzsomerkey
X-Tangled-Signature-256: sha256=9f2c…
HeaderValue
Content-Typeapplication/json
User-AgentTangled-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-EventThe event name, e.g. push or pull_request:merged.
X-Tangled-Hook-IDThe webhook’s numeric id on the spindle.
X-Tangled-DeliveryA UUID, new for every delivery including retries.
X-Tangled-RepoThe repository’s full_name (see below).
X-Tangled-Signature-256sha256=<hex HMAC>, only when the webhook has a secret.

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" }
}
FieldMeaning
full_name<owner DID>/<record key> of the repository record. It doesn’t change on rename.
descriptionAlways empty in the current spindle.
forkAlways false in the current spindle.
html_url, clone_url, ssh_urlBuilt from the repository’s knot hostname, owner DID and record key.
created_at, updated_atWhen the spindle learned about the repository. Both carry the same value.
owner.didThe repository owner’s DID.
website, stars_count, open_issues_countDefined but never sent by the current spindle.
{
"ref": "refs/heads/main",
"before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e",
"after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5",
"pusher": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" },
"repository": { "...": "see above" }
}
FieldMeaning
refThe full ref that changed, e.g. refs/heads/main or refs/tags/v1.0.0.
beforeThe ref’s previous commit.
afterThe ref’s new commit.
pusher.didThe 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.

{
"old_name": "some-repo",
"new_name": "better-name",
"repository": { "...": "see above, with the new name" },
"sender": { "did": "did:plc:hwevmowznbiukdf6uk5dwrrq" }
}

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" }
}
FieldMeaning
actionThe event name’s suffix: created, resubmitted, merged, closed or reopened.
pull_request.numberAlways 0 in the current spindle. Don’t use it to identify the pull request.
pull_request.title, bodyFrom the pull request. body is empty when it has none.
pull_request.statemerged for merged, closed for closed, otherwise open.
pull_request.target_branchThe branch the pull request targets.
pull_request.sourcePresent 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_numberHow many rounds the pull request has.
pull_request.owner.didThe pull request’s author.
pull_request.created_atWhen the pull request was opened.
sender.didWho 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.

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.

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.

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}`,
}),
});
},
);