# Webhooks

> Send a repository's push, rename and pull request events to your own HTTP endpoint, with the headers, payloads and signatures it receives.

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

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.

## 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

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.

## Headers

```http
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…
```

| 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

### The repository object

Every payload has a `repository` object:

```json
{
  "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.                                    |

### Push

```json
{
  "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

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

### Pull request

All `pull_request:*` events share one shape:

```json
{
  "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

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.

```go
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

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

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

> **Caution**
>
> Webhooks live on the spindle. Switching the repository to another spindle makes the old spindle delete the repository’s webhooks and delivery history; recreate them on the new spindle.