# Push Webhooks

> Setting up a per-source push webhook for GitHub, GitLab, Gitea, Bitbucket and Confluence Data Center, what happens on delivery, and rotating a secret.

- Updated: 2026-09-25
- Source: https://contextator.com/en/docs/push-webhooks/
- Language: en-US
- Author: Muhammet Şafak

---
Re-index automatically when someone pushes to a repository. Every git source gets its own webhook URL
and its own secret.

```
POST http://<your-host>/api/webhooks/git/<source-id>
```

---

## Setting one up

1. Open the git source in the dashboard (**Edit** on its row).
2. The **Push webhook** box shows the URL and the secret, with copy buttons.
3. In the repository settings, add a **push** webhook with that URL and that secret.
4. Push something. The project's status moves to `queued` within a second or two.

The server must be reachable from the git host. For a self-hosted Gitea on the same network that is
usually automatic; for GitHub.com you need a public address or a tunnel.

## Per provider

### GitHub
**Settings → Webhooks → Add webhook**

| Field | Value |
|-------|-------|
| Payload URL | the webhook URL |
| Content type | `application/json` |
| Secret | the secret from the dialog |
| Events | *Just the push event* |

GitHub signs with `X-Hub-Signature-256`.

### GitLab
**Settings → Webhooks**

| Field | Value |
|-------|-------|
| URL | the webhook URL |
| Secret token | the secret from the dialog |
| Trigger | *Push events* |

GitLab sends the secret itself in `X-Gitlab-Token`.

### Gitea / Forgejo / Codeberg
**Settings → Webhooks → Gitea**

| Field | Value |
|-------|-------|
| Target URL | the webhook URL |
| HTTP method | `POST` |
| POST content type | `application/json` |
| Secret | the secret from the dialog |
| Trigger | *Push events* |

Signed with `X-Gitea-Signature` (newer versions also send `X-Hub-Signature-256`).

### Bitbucket Cloud
**Repository settings → Webhooks → Add webhook**, triggering on *Repository push*, with the secret from
the dialog. Signed with `X-Hub-Signature`.

---

## Confluence Data Center

Not a git host, but the same idea for a [Confluence](/en/docs/confluence/) source on Data Center, with
its own URL (ADR-0085):

```
POST http://<your-host>/api/webhooks/confluence/<source-id>
```

A Confluence source has **no webhook until you turn it on**; until then every delivery is refused with
`401 not_enabled` and nothing is stored.

1. **Edit** the Confluence source and choose **Turn on** in the *Confluence webhook* box. A secret is
   generated; **Copy URL** and **Copy secret**.
2. In Confluence, **Administration → Webhooks → Create a webhook**: paste the URL and the secret and
   choose the page events (created, updated, removed, restored, moved).

Confluence signs each delivery with `X-Hub-Signature: sha256=<HMAC-SHA256 of the body>`; one that does
not match is refused with `401 invalid_signature`. Comments, labels, attachments, likes, user and group
changes and **blog posts** cannot change what is indexed — they are answered `200` and ignored. Anything
else, including permission changes and an event this build does not know, queues a run.

A burst of edits becomes one run, and runs are at least `WEBHOOK_MIN_INTERVAL_MINUTES` apart (5 by
default): a webhook makes the source **look soon**, not within seconds. Every answer that is not a
refusal is `200`, because Confluence counts anything else as a failure and stops delivering after a run
of them. Once on, the button reads **New secret** and replaces the secret; **Turn off** removes it
(`DELETE …/webhook-secret`), and deliveries are refused again.

It does not see everything — a delivery Confluence gave up on, a page that became unreadable to the
account without an event, deliveries Confluence skips for hours after repeated failures — so **keep the
sync interval on**; the next scheduled sync catches what the webhook missed. Confluence Cloud cannot
send these without a Forge or Connect app — see [Confluence](/en/docs/confluence/#how-it-stays-fresh).

---

## What happens on delivery

1. The signature is verified against that source's secret — **before anything else happens**.
2. The branches in the payload are compared with the source's branch. A push to another branch is
   ignored.
3. A re-index of the project is queued, which syncs every source of that project (not only this one).

Responses: `202` queued · `200` ignored (other branch) · `401 invalid_signature` · `404` unknown source.

## Rotating the secret

**Regenerate** in the source dialog issues a new secret and invalidates the old one immediately. Paste
the new one into the repository settings; until you do, deliveries return `401`.

## Security notes

- This endpoint is the one part of `/api/*` that takes neither an account nor `ADMIN_TOKEN` — the git
  host can hold neither. Its entire defence is the signature, which is verified against the raw body
  with a constant-time comparison before any work is queued.
- Each source has its own secret, so a leak affects one source and is rotated on its own.
- The worst a leaked secret allows is triggering re-index runs for that source's project. No data is
  exposed by the endpoint.

## Troubleshooting

| Symptom | Cause |
|---------|-------|
| `401 invalid_signature` | The secret in the repository settings is not the one currently stored. Copy it again, or regenerate and paste the new one |
| Delivery succeeds but nothing indexes | The push was to a different branch than the source's. Check the branch in the source dialog |
| The git host cannot reach the URL | The server is not reachable from the internet. Use a tunnel, or fall back to a scheduled `POST /api/projects/:id/reindex` |
| `404` | The source was deleted, or it is not a git source |
| Confluence: `401 not_enabled` | The source's webhook is off. **Turn on** in the *Confluence webhook* box and paste the new secret into Confluence |

## Without webhooks

Any scheduled job can do the same thing through the [Admin API](/en/docs/admin-api/):

```bash
curl -X POST http://localhost:3444/api/projects/$PROJECT_ID/reindex \
     -H "Authorization: Bearer $ADMIN_TOKEN"
```

`ADMIN_TOKEN` is the credential for a job like this — it is meant for scripts, and a session cookie is
not something a cron entry can hold. See [Admin API](/en/docs/admin-api/).

Because indexing is incremental, running this every few minutes costs almost nothing when nothing
changed — and Notion is not exempt so much as different: once verified, it delivers its own push
webhook, with the secret running the other direction from the pattern above. See
[Notion](/en/docs/notion/#keeping-it-in-sync) for how it is verified and what that means for sync.
