Webhooks
- Register a webhook on a repository
- Register a webhook on a group
- Verify the signature on an incoming request
- See which webhooks are registered
- Check one webhook's configuration
- Send a test delivery
- Review recent deliveries
- Look up a single delivery
- Replay a delivery
- Change what a webhook subscribes to
- Rotate the signing secret
- Pause a webhook without deleting it
- Delete a webhook
Overview
A webhook makes Gitmoap call your own HTTPS endpoint the moment something happens — a push lands, a merge request is merged, a work item closes — so your systems react to the platform instead of polling it. Register one on a single repository or on a whole group, pick the event types you care about, and every matching event arrives as a signed JSON POST within seconds.
Every delivery is recorded. When your receiver was down, or you shipped a bug in it, you can see exactly which deliveries failed and what the response was, and replay any of them once the receiver is healthy again.
Quick start
Register your first webhook
Choose Settings, then Webhooks, then New webhook.
Paste the endpoint's URL, set a signing secret you have stored, and tick the events you care about.
Choose Create webhook, then Send test from its deliveries to watch a signed request arrive.
Before you start
- Maintainer access on the repository or the group.This is the bar for reading webhooks as well as changing them — a webhook holds a signing secret and points at a private endpoint, so it is not visible to Developers or Reporters the way branches and commits are.
- A token with the right scope, if you are calling the API or the CLI rather than clicking:
webhook:readto list a scope's webhooks and browse deliveries,webhook:manageto create, edit, delete, test and replay. Scopes are chosen when you mint a token and cannot be added afterwards, so a monitoring script that only reads deliveries should carrywebhook:readalone. Addrepo:readorgroup:readif you want to pass a path such asacme/platform/api-gatewayto the CLI instead of an id — that convenience is a lookup, and the lookup is a separate call with its own scope. - An HTTPS endpoint on a publicly resolvable host.Plain
http://is refused, and so arelocalhost, loopback, private and link-local addresses — both at registration and again at connection time, so a public hostname that resolves to a private address fails to deliver. To receive events on a laptop, put a tunnel with a public HTTPS hostname in front of it. - A signing secret you generate yourself, and somewhere for your receiver to read it from.
openssl rand -hex 32is a good source. You supply the secret when you create the webhook; nothing ever reads it back to you, so store it before you submit. - A receiver that answers quickly.Each attempt has a ten-second budget, so acknowledge with a
2xxfirst and do the work afterwards.
How it works
Where a webhook lives, and how many fire
A webhook belongs to exactly one scope: one repository, or one group. When an event happens in a repository, we deliver it to that repository's own webhooks and to the webhooks of every group above it, all the way to the top of the tree.
acme group webhook → fires└── platform group webhook → fires └── api-gateway repo webhook → firesA push to acme/platform/api-gateway in the tree above reaches all three. That is the mechanism that makes one group webhook enough for an entire department — and the reason you get three copies of everything if you also register the same endpoint on each repository underneath. Register once, at the highest scope that covers what you want.
Work items are the one case where a group webhook sees something a repository webhook cannot: an epic filed directly in a group has no repository to belong to, so it reaches group webhooks only.
A webhook fires only when it is active and its events list contains the event type. Both filters are applied at delivery time, so pausing a webhook or narrowing its event list takes effect on the next event with no other step.
What you can subscribe to
An events list is built from five names. Each payload names the specific thing that happened in its own action field.
| Event type | Fires when | action on the payload |
|---|---|---|
push | a git push updates one or more refs in the repository | none — the payload carries a ref_updates array, one entry per updated ref with its before and after commit |
merge_request | a merge request is opened, edited, merged, closed or reopened | opened, updated, merged, closed, reopened |
issue | a work item changes | opened, updated, status_changed, closed, reopened, moved, reparented, assigned, unassigned, commented, linked_to_commit, linked_to_merge_request, verified, regressed |
tag_push | never — the name is accepted in an events list, but a tag pushed with git reaches you on push instead, as a refs/tags/… entry in ref_updates | — |
access | who can do what changes, on this group or repository or anywhere beneath it: access given, changed, revoked or about to end; a limit set or removed; inheritance switched; a team created or deleted, or someone joining or leaving one; a custom role defined, changed or deleted | granted, changed, revoked, expiring, expired, limit_set, limit_removed, inheritance_changed, team_created, team_deleted, team_member_added, team_member_removed, role_created, role_updated, role_deleted |
Note
A push arrives when somebody runs git push, and at no other time. Merging a merge request moves the target branch from inside Gitmoap without a push, and so does creating or deleting a branch or a tag through the API or the web app. Subscribe to merge_request alongside push when what you want to know is every time the default branch moves.
Every payload names its type in an event field and its originator in a sender object. push and merge_request also carry a repository object with the repository's id, name and full path. An issue payload carries scope instead — {"repo": "<id>"} or {"group": "<id>"} — because a work item can belong to either.
Beyond those shared fields, each type carries the subject it is about:
| Event type | The subject on the body |
|---|---|
push | ref_updates — an array of { ref_name, before, after }, one per updated ref — and pushed_at |
merge_request | merge_request — { id, title, status, source_branch, target_branch } |
issue | issue — { uid, iid, reference, work_item_type, title, status_id, status_category }. The uid is the identity that survives a move between scopes; iid and reference are the address, and both change when the item moves |
A push payload looks like this:
{ "event": "push", "repository": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "api-gateway", "full_path": "acme/platform/api-gateway" }, "sender": { "id": "3f1b...", "username": "rlee", "email": "rlee@acme.example", "avatar_url": null }, "ref_updates": [ { "ref_name": "refs/heads/main", "before": "9c1f2a…", "after": "4d80be…" } ], "pushed_at": "2026-09-04T09:21:14Z"}A ref the push created has an all-zero before, and one it deleted has an all-zero after — that is how a create, an update and a delete are told apart, and it is why a ref_updates entry always carries both. The push payload is also the only one with an event time of its own; date a merge-request or work-item payload from your own receipt.
A sender may be a service account. A bot pushing with its own token is delivered under that account's name and generated email, not under whoever created it — so a receiver that looks the sender up as a person will not find one. Match on the id, or treat an unknown sender as a bot rather than as an error.
The request we send
Every delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-Gitmoap-Event | the event type the delivery carries — push, merge_request or issue |
X-Gitmoap-Delivery | the id of this delivery — every attempt at it, the first and any retry, carries the same value |
X-Gitmoap-Signature-256 | sha256= followed by the hex HMAC-SHA256 of the exact body bytes, keyed with your secret |
Content-Type | application/json |
User-Agent | Gitmoap-Webhook/1.0 |
Deliveries are at-least-once: a rare retry inside the platform can send the same event twice with a different X-Gitmoap-Delivery, and a replay you request yourself always gets a new one. Make your handler idempotent on the content it acts on rather than assuming one delivery per event.
Redirects are not followed. A 301 or 302 from your endpoint is recorded as a failed delivery, so register the final URL rather than a redirecting one.
Retries, and what a delivery record holds
We treat a delivery as transient or final based on what your endpoint does:
| What your endpoint does | What we do |
|---|---|
answers 2xx | success — recorded, no retry |
answers 4xx | failure — recorded, no retry, because a rejection is a decision |
answers 5xx | retried, up to three attempts in total |
| answers nothing within ten seconds, refuses the connection, or fails to resolve | retried, up to three attempts in total |
Backoff between attempts is 200 ms and then 400 ms, so a refused connection is exhausted in well under a second while an endpoint that accepts the connection and then goes quiet takes the ten-second budget three times over. All three attempts produce one delivery record, describing the last of them — a record marked failed after three tries is one line in the list, not three.
A delivery record holds the event type, whether it succeeded, the response status your endpoint gave, how long we spent on it — every attempt and the waits between them — and when it was triggered. A delivery that never reached your endpoint at all — DNS failure, refused connection, timeout — is recorded as unsuccessful with no status code, which is the fastest way to tell "my endpoint rejected it" apart from "my endpoint was never reached". The full request and response are kept behind the record so a replay can send the original body again; only the outcome comes back over the API.
How to
Register a webhook on a repository
Pick the events, give the endpoint and the secret — all three are required. Pick at least one event type: an empty list is accepted and registers a webhook that is subscribed to nothing. New webhooks are active immediately unless you say otherwise, so have the receiver deployed before you create one — or create it inactive and switch it on afterwards.
Open the repository, choose Settings, then Webhooks, then New webhook. Paste the endpoint URL, paste or generate the signing secret, and tick the event types you want. Active is on by default; clear it if the receiver is not deployed yet. Add webhook creates it and returns you to the list, where the new hook is at the top.
gitmoap webhooks create \ --repo-id <id> \ --events '<json>' \ --secret <secret> \ --url <url>curl -X POST https://api.gitmoap.example/repositories/{repo_id}/webhooks \ -H "Authorization: Bearer $GITMOAP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "secret": "<password>", "url": "<string>" }'The optional name is for you: with several webhooks on one repository, a list of URLs is much harder to read than a list of names like pagerduty and staging-deploy.
Register a webhook on a group
A group webhook covers every repository in the group, including repositories in subgroups, and continues to cover new repositories as they are created. It is the right level for anything that is about the team rather than about one codebase — a chat notifier, an audit sink, a deployment tracker.
Open the group, choose Settings, then Webhooks, then New webhook. The form is the same as a repository's; the difference is the reach, which the form states above the URL field. Add webhook creates it.
gitmoap webhooks create-group \ --group-id <id> \ --events '<json>' \ --secret <secret> \ --url <url>curl -X POST https://api.gitmoap.example/groups/{group_id}/webhooks \ -H "Authorization: Bearer $GITMOAP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "secret": "<password>", "url": "<string>" }'Group webhooks are also the only way to receive events for work items filed directly in a group.
Verify the signature on an incoming request
Never act on a request you have not verified. Anyone who learns your endpoint URL can post to it; the signature is what proves the body came from Gitmoap and arrived unmodified.
Read the raw request body as bytes, before any JSON parsing. Parsing and re-serialising changes whitespace and key order, and the signature is over the exact bytes we sent.
Compute
HMAC-SHA256(secret, body)and hex-encode it.Prefix it with
sha256=and compare it againstX-Gitmoap-Signature-256using a constant-time comparison.Reject with
401when it does not match. Only then parse the JSON.
Worked receivers in two languages are in Examples.
See which webhooks are registered
The list gives each webhook's endpoint, its event types, whether it is active and who created it, newest first. Secrets are never included.
Open the repository, choose Settings, then Webhooks. Each row shows the endpoint, the events it subscribes to and a badge when it is paused. Selecting a row opens its detail view with the delivery history.
gitmoap webhooks list --repo-id <id>curl -X GET https://api.gitmoap.example/repositories/{repo_id}/webhooks \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the group, choose Settings, then Webhooks. The list covers webhooks owned by this group only — a subgroup's own webhooks are on the subgroup's page, and a repository's are on the repository's.
gitmoap webhooks list-group --group-id <id>curl -X GET https://api.gitmoap.example/groups/{group_id}/webhooks \ -H "Authorization: Bearer $GITMOAP_TOKEN"Because a repository is also covered by every group above it, the repository's list is not the whole answer to "what fires on a push here". Check the ancestor groups too when you are chasing a delivery you cannot account for.
Check one webhook's configuration
Open the repository's Webhooks list and select the webhook. The detail view shows the endpoint, event types, active state, who created it and when it was last changed, above the delivery history.
gitmoap webhooks get --repo-id <id> --webhook-id <id>curl -X GET https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the group's Webhooks list and select the webhook. The layout is identical to a repository's.
gitmoap webhooks get-group --group-id <id> --webhook-id <id>curl -X GET https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Send a test delivery
A test posts a small synthetic payload, signed with the real secret and carrying the same headers as a real event. It is the fastest way to prove that the URL resolves, the TLS certificate is trusted and the signature check in your receiver agrees with ours.
{ "event": "push", "test": true, "webhook_id": "5c2a1f3e-9d44-4b0a-8f21-6b7c0d5e4a13", "message": "This is a test webhook delivery from Gitmoap"}The header is X-Gitmoap-Event: push, so a receiver that switches on the event header handles a test along its push path — but the body has no repository, no sender and no ref_updates. Branch on test before you read any of them, or a test delivery crashes the handler it was meant to prove.
Open the webhook's detail view and choose Send test. The new delivery appears at the top of the delivery history within a few seconds; refresh if it has not landed.
gitmoap webhooks test --repo-id <id> --webhook-id <id>curl -X POST https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id}/test \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the group webhook's detail view and choose Send test. It behaves exactly as the repository version.
gitmoap webhooks test-group --group-id <id> --webhook-id <id>curl -X POST https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id}/test \ -H "Authorization: Bearer $GITMOAP_TOKEN"A test is sent even when the webhook is paused, and even when its events list does not include push, which is deliberate: it lets you check a new endpoint before you switch it on for real traffic. The call returns as soon as the test is accepted, so a 200 here means "queued", not "your endpoint answered" — the delivery record is where you find out.
Review recent deliveries
Deliveries are listed newest first, fifty at a time unless you ask for a different page size, with a token for the next page.
Open the webhook's detail view; the delivery history is the lower half. Each row shows the event type, the time, the response status and the duration, with failures marked. Load more fetches the next page.
gitmoap webhooks list-deliveries --repo-id <id> --webhook-id <id>curl -X GET https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id}/deliveries \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the group webhook's detail view. The history is the same, and covers deliveries raised by every repository under the group.
gitmoap webhooks list-group-deliveries --group-id <id> --webhook-id <id>curl -X GET https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id}/deliveries \ -H "Authorization: Bearer $GITMOAP_TOKEN"Scan the status column first. A run of 4xx means your receiver is rejecting us — often a signature check comparing against a re-serialised body. A run of failures with no status at all means we never got a response, so look at DNS, the certificate and the ten-second budget.
Look up a single delivery
Select a row in the delivery history. The detail panel shows the event type, the outcome, the response status and how long the delivery took across every attempt.
gitmoap webhooks get-delivery \ --repo-id <id> \ --webhook-id <id> \ --delivery-id <id>curl -X GET https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Select a row in the group webhook's delivery history. The panel is the same.
gitmoap webhooks get-group-delivery \ --group-id <id> \ --webhook-id <id> \ --delivery-id <id>curl -X GET https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id}/deliveries/{delivery_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Replay a delivery
A replay sends the original body again, under the original event type, with a new delivery id and a fresh signature — computed with whatever secret and posted to whatever URL the webhook holds now. That is what makes it the repair tool after an outage: fix the receiver, then replay the failures without asking anyone to push again.
Open the delivery in the webhook's history and choose Redeliver. A new delivery appears at the top of the list; the original row is left exactly as it was.
gitmoap webhooks redeliver \ --repo-id <id> \ --webhook-id <id> \ --delivery-id <id>curl -X POST https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the delivery in the group webhook's history and choose Redeliver. It behaves identically.
gitmoap webhooks redeliver-group \ --group-id <id> \ --webhook-id <id> \ --delivery-id <id>curl -X POST https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver \ -H "Authorization: Bearer $GITMOAP_TOKEN"Replay is not deduplicated — press it twice and your endpoint is called twice, with two different delivery ids. Like a test, a replay is sent even to a paused webhook, and even when the event type has since been dropped from the webhook's events list, so narrowing a subscription does not put the older deliveries beyond replay. The call returns as soon as the replay is accepted rather than when your endpoint answers.
Change what a webhook subscribes to
Every field is individually optional: send only what changes. Sending an events list replaces the list rather than adding to it, so include every type you want to keep.
Open the webhook's detail view and choose Edit. Change the endpoint, the name, the event ticks or the active switch, then Save. The change applies to the next event; deliveries already in flight are unaffected.
gitmoap webhooks update --repo-id <id> --webhook-id <id>curl -X PATCH https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the group webhook's detail view and choose Edit. The form is the same as a repository webhook's.
gitmoap webhooks update-group --group-id <id> --webhook-id <id>curl -X PATCH https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Rotate the signing secret
Rotation is an edit that sets a new secret; nothing else about the webhook changes, and there is no window in which both secrets are accepted. Sequence it so your receiver is never the odd one out.
Generate a new secret —
openssl rand -hex 32.Teach your receiver to accept either the old or the new signature, and deploy that.
Edit the webhook and set the new secret. Send a test delivery and confirm it succeeded.
Remove the old secret from your receiver and deploy again.
The old secret cannot be read back, so if you skip step 2 and the rollout goes wrong, the way out is another rotation rather than a revert.
Pause a webhook without deleting it
Set the webhook inactive. It keeps its endpoint, its secret, its event list and its whole delivery history, and stops receiving events; events raised while it is paused are not queued and do not arrive when you switch it back on. Pausing is the right move for a receiver that is being redeployed, and it is always better than deleting and recreating, which loses the history and the secret.
Delete a webhook
Warning
Deleting a webhook is immediate and cannot be undone. It takes the endpoint, the signing secret and the entire delivery history with it, so any failed delivery still awaiting a replay becomes unreplayable. Events raised while no webhook exists are not stored anywhere, so recreating it later does not backfill anything. If you are stopping traffic temporarily, set the webhook inactive instead.
Open the repository's Webhooks list, then the bin icon at the end of the webhook's row. The confirmation names the endpoint; Delete is immediate.
gitmoap webhooks delete --repo-id <id> --webhook-id <id>curl -X DELETE https://api.gitmoap.example/repositories/{repo_id}/webhooks/{webhook_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Open the group's Webhooks list and use the bin icon on the row. Deleting a group webhook stops deliveries for every repository under that group at once.
gitmoap webhooks delete-group --group-id <id> --webhook-id <id>curl -X DELETE https://api.gitmoap.example/groups/{group_id}/webhooks/{webhook_id} \ -H "Authorization: Bearer $GITMOAP_TOKEN"Reference
18 endpoints
POST/repositories/{repo_id}/webhookswebhook:manage
Create a webhook for a repository
Token scope
webhook:manage
Counts against
webhooks_per_scope
Send
Path
- repo_iduuidRepository ID
Body
- activebooleanoptionalWhether deliveries are sent. Defaults to
true. - nameobjectoptional
- secretpasswordThe secret each delivery is signed with, so your endpoint can check it came from gitmoap. Stored encrypted and never returned.
- urlstring
Refuses
400Invalid request401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Repository not found409The new webhook collided with a stored record; nothing was created, retry429Webhook limit reached for this repository
Receive
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
GET/repositories/{repo_id}/webhookswebhook:read
List webhooks for a repository
Token scope
webhook:read
Send
Path
- repo_iduuidRepository ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Repository not found
Receive
items[]object12 fields
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
- next_tokenobjectoptional
GET/repositories/{repo_id}/webhooks/{webhook_id}webhook:read
Get a webhook by ID
Token scope
webhook:read
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found
Receive
The same shape create_webhook returns — 9 fields. Go to it
Show it here
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
PATCH/repositories/{repo_id}/webhooks/{webhook_id}webhook:manage
Update a webhook
Token scope
webhook:manage
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
Body
- activeobjectoptional
- nameobjectoptional
- secretpasswordoptional
- urlobjectoptional
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
The same shape create_webhook returns — 9 fields. Go to it
Show it here
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
DELETE/repositories/{repo_id}/webhooks/{webhook_id}webhook:manage
Delete a webhook
Token scope
webhook:manage
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
POST/groups/{group_id}/webhookswebhook:manage
Create a webhook for a group
Token scope
webhook:manage
Counts against
webhooks_per_scope
Send
Path
- group_iduuidGroup ID
Body
- activebooleanoptionalWhether deliveries are sent. Defaults to
true. - nameobjectoptional
- secretpasswordThe secret each delivery is signed with, so your endpoint can check it came from gitmoap. Stored encrypted and never returned.
- urlstring
Refuses
400Invalid request401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Group not found409The new webhook collided with a stored record; nothing was created, retry429Webhook limit reached for this group
Receive
The same shape create_webhook returns — 9 fields. Go to it
Show it here
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
GET/groups/{group_id}/webhookswebhook:read
List webhooks for a group
Token scope
webhook:read
Send
Path
- group_iduuidGroup ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Group not found
Receive
The same shape list_webhooks returns — 2 fields. Go to it
Show it here
items[]object12 fields
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
- next_tokenobjectoptional
GET/groups/{group_id}/webhooks/{webhook_id}webhook:read
Get a group webhook by ID
Token scope
webhook:read
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found
Receive
The same shape create_webhook returns — 9 fields. Go to it
Show it here
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
PATCH/groups/{group_id}/webhooks/{webhook_id}webhook:manage
Update a group webhook
Token scope
webhook:manage
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
Body
- activeobjectoptional
- nameobjectoptional
- secretpasswordoptional
- urlobjectoptional
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
The same shape create_webhook returns — 9 fields. Go to it
Show it here
- activeboolean
- created_atstring
created_byobject4 fields
- avatar_urlobjectoptional
- emailstring
- idstring
- usernamestring
- nameobjectoptional
- scope_idstring
- scope_typestring
- updated_atstring
- urlstring
- webhook_iduuid
DELETE/groups/{group_id}/webhooks/{webhook_id}webhook:manage
Delete a group webhook
Token scope
webhook:manage
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
GET/repositories/{repo_id}/webhooks/{webhook_id}/deliverieswebhook:read
List webhook deliveries
list_webhook_deliveriesGET /repositories/{repo_id}/webhooks/{webhook_id}/deliveriesOpen in the API referenceToken scope
webhook:read
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
Query
- limitint32optionalMax items to return
- next_tokenstringoptionalPagination token
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Webhook not found
Receive
items[]object7 fields
- delivery_iduuid
- duration_msint32optional
- event_typeaccessissuemerge_requestpushtag_push
- status_codeint32optional
- successboolean
- triggered_atstring
- webhook_iduuid
- next_tokenobjectoptional
GET/repositories/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}webhook:read
Get a specific webhook delivery
get_webhook_deliveryGET /repositories/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}Open in the API referenceToken scope
webhook:read
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
- delivery_iduuidDelivery ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found
Receive
- delivery_iduuid
- duration_msint32optional
- event_typeaccessissuemerge_requestpushtag_push
- status_codeint32optional
- successboolean
- triggered_atstring
- webhook_iduuid
POST/repositories/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliverwebhook:manage
Redeliver a webhook delivery
redeliver_webhookPOST /repositories/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliverOpen in the API referenceToken scope
webhook:manage
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
- delivery_iduuidDelivery ID
Refuses
400The original request for this delivery is no longer kept, so it cannot be sent again401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
POST/repositories/{repo_id}/webhooks/{webhook_id}/testwebhook:manage
Send a test delivery for a webhook
Token scope
webhook:manage
Send
Path
- repo_iduuidRepository ID
- webhook_iduuidWebhook ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
GET/groups/{group_id}/webhooks/{webhook_id}/deliverieswebhook:read
List group webhook deliveries
list_group_webhook_deliveriesGET /groups/{group_id}/webhooks/{webhook_id}/deliveriesOpen in the API referenceToken scope
webhook:read
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
Query
- limitint32optionalMax items to return
- next_tokenstringoptionalPagination token
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Webhook not found
Receive
The same shape list_webhook_deliveries returns — 2 fields. Go to it
Show it here
items[]object7 fields
- delivery_iduuid
- duration_msint32optional
- event_typeaccessissuemerge_requestpushtag_push
- status_codeint32optional
- successboolean
- triggered_atstring
- webhook_iduuid
- next_tokenobjectoptional
GET/groups/{group_id}/webhooks/{webhook_id}/deliveries/{delivery_id}webhook:read
Get a specific group webhook delivery
get_group_webhook_deliveryGET /groups/{group_id}/webhooks/{webhook_id}/deliveries/{delivery_id}Open in the API referenceToken scope
webhook:read
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
- delivery_iduuidDelivery ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found
Receive
The same shape get_webhook_delivery returns — 7 fields. Go to it
Show it here
- delivery_iduuid
- duration_msint32optional
- event_typeaccessissuemerge_requestpushtag_push
- status_codeint32optional
- successboolean
- triggered_atstring
- webhook_iduuid
POST/groups/{group_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliverwebhook:manage
Redeliver a group webhook delivery
redeliver_group_webhookPOST /groups/{group_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliverOpen in the API referenceToken scope
webhook:manage
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
- delivery_iduuidDelivery ID
Refuses
400The original request for this delivery is no longer kept, so it cannot be sent again401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
POST/groups/{group_id}/webhooks/{webhook_id}/testwebhook:manage
Send a test delivery for a group webhook
Token scope
webhook:manage
Send
Path
- group_iduuidGroup ID
- webhook_iduuidWebhook ID
Refuses
400Bad request — a path parameter is not a well-formed value of its type401Unauthenticated — no credential, or an invalid one403Forbidden — the caller's access level or token scope is insufficient404Not found409The request conflicts with the resource's current state; `reason` says how
Receive
No body.
Limits
Webhooks are capped per repository and per group, and the two budgets are separate — a repository can hold its full allowance while every group above it holds its own, all of which fire on that repository's events.
| Plan | Webhooks per repository or group |
|---|---|
| Free | 20 |
| Pro | 50 |
| Team | 200 |
| Enterprise | 500 |
One over is refused outright, with a message naming the webhook quota. Nothing is half-created and nothing is consumed by the refusal, so a retry after you free a slot behaves exactly as the first attempt would have.
A group's own webhook count is on its usage and limits view, alongside every other cap the group is measured on, so you can see how close you are before a request is refused rather than afterwards. That count covers the webhooks the group itself owns, not those of its subgroups or its repositories. A repository has no such view — count the rows in its webhook list.
Consolidation is usually a better answer than a bigger number: several repository webhooks pointing at one receiver become a single group webhook that also covers repositories created later, and the receiver already knows where an event came from because every payload names its repository or its scope. A receiver that fans out to several internal consumers is one webhook, not several. Where you genuinely need more, an Owner can move the namespace to a higher plan or request an increase on this one limit, which is raisable as far as 5,000.
Troubleshooting
400 BAD_REQUESTmentioning url, "must use HTTPS"
- Cause
- The endpoint is
http:// - What to do
- Serve the receiver over HTTPS with a publicly trusted certificate and register the
https://URL. Plain HTTP is refused at registration, not at delivery
400 BAD_REQUESTmentioning an internal, loopback or link-local address
- Cause
- The URL names
localhost, a127.x,10.x,172.16–31.x,192.168.x,169.254.xor unique-local address - What to do
- Put a public HTTPS hostname in front of the receiver — a tunnel for local development, a load balancer otherwise. A public hostname that resolves to a private address passes registration and then fails every delivery, so check what your DNS actually returns
400 BAD_REQUESTmentioning url and a length
- Cause
- The URL is empty or longer than 2048 characters
- What to do
- Shorten it. Move any long routing information into a path segment your receiver reads, or into the receiver's own configuration
400 BAD_REQUESTnaming an event type
- Cause
- An
eventsentry is not one ofpush,tag_push,merge_request,issue,access - What to do
- Use one of the five exactly as spelled. Event names are lower case with underscores;
merge-requestandMergeRequestare both refused
400 BAD_REQUESTnaming a field you did not expect to be a problem
- Cause
- The request body carries a field the endpoint does not define — usually a typo such as
secretsorevent - What to do
- Remove the unknown field. Unknown fields are rejected rather than ignored, so a silent typo cannot quietly disable a subscription
400 BAD_REQUESTon any call, mentioning an id
- Cause
- A path segment is not a well-formed id — often a path like
my-group/my-repopasted where a repository id belongs - What to do
- Use the id. The CLI also takes a path for
--repo-idand--group-idand looks the id up for you, which needsrepo:readorgroup:readon the token alongside the webhook scope
400 BAD_REQUESTon redeliver
- Cause
- The stored request for that delivery is no longer available to replay
- What to do
- Trigger the event again — push, or edit the merge request — or send a test delivery if you only need to prove the receiver works. Newer deliveries replay normally
401 UNAUTHORIZED
- Cause
- No credential was sent, or the token has expired or been revoked
- What to do
- Sign in again, or mint a new token. A token revoked from the access-token list stops working immediately
403 FORBIDDENon a repository or group you can otherwise read
- Cause
- Your access level is below Maintainer on the owning repository or group
- What to do
- Ask an Owner or Maintainer to raise your access level. Webhooks are Maintainer-and-above for reading as well as writing, so Developer access is not enough even to list them
403 INSUFFICIENT_SCOPE
- Cause
- The token is valid but was minted without the scope this call needs
- What to do
- Mint a new token carrying
webhook:readfor the listing and detail calls, orwebhook:managefor create, edit, delete, test and replay. Scopes are fixed at mint time
404 NOT_FOUNDon a repository or group you are sure exists
- Cause
- The resource is private and you are not a member, or the id is wrong
- What to do
- A private resource answers
404rather than403so that its existence stays private. Confirm your membership, then confirm the id
404 NOT_FOUNDon a webhook or a delivery
- Cause
- The webhook was deleted, the delivery belongs to a different webhook, or you are asking the group endpoint about a repository webhook
- What to do
- List the scope's webhooks and use an id from that list. A repository webhook is not reachable through the group path even when the group owns the repository
409 CONFLICTon create
- Cause
- The new webhook collided with a record already stored under the same id
- What to do
- Retry the request. Nothing was created and nothing was counted against the cap, so the retry behaves exactly as the first attempt would have. There is no duplicate check on the URL — two webhooks on one scope may point at the same endpoint, so this is never what a
409means
429 TOO_MANY_REQUESTSon create, naming the webhook quota
- Cause
- That repository or that group is at its webhook cap for the plan — 20 on Free
- What to do
- Delete one you no longer need, or replace several repository webhooks with one on the parent group. Nothing was created by the refused request
400 BAD_REQUESTon create, naming url, secret or events
- Cause
- One of the three required fields is missing — a create needs all of them, unlike an edit, where every field is optional
- What to do
- Send all three. To create a webhook you are not ready to receive on, send
"active": falserather than leaving a field out
Nothing arrives after a push, and no delivery is recorded
- Cause
- The webhook is inactive, or its
eventslist does not include the event type - What to do
- Open the webhook and check both. An empty
eventslist is accepted at create time and subscribes you to nothing, so confirm the list is not empty as well as correct. Then check the ancestor groups — the hook that you expected to fire may be registered on a different repository
Nothing arrives when a tag is pushed, though tag_push is subscribed
- Cause
- A tag reaches a receiver on the
pushevent - What to do
- Subscribe to
pushand look for arefs/tags/…entry inref_updates. An all-zerobeforeis a tag being created, an all-zeroafterone being deleted
The default branch moved and no push arrived
- Cause
- The move was a merge request being merged, which does not push
- What to do
- Subscribe to
merge_requestand act on themergedaction. A branch or tag created or deleted through the API or the web app is the same shape of gap, and delivers no webhook of any type
Deliveries arrive in triplicate
- Cause
- The same endpoint is registered on a repository and on one or more groups above it
- What to do
- Keep one registration, at the highest scope that covers what you want, and delete the rest. Each registration is a delivery in its own right with its own id, so no header collapses them for you — check the webhook list on every ancestor group to find the extras
Your receiver answers 401, and the signature never matches
- Cause
- The HMAC is computed over a re-serialised body rather than the raw bytes, the
sha256=prefix is included on one side only, or the secret was rotated without redeploying - What to do
- Sign the exact bytes received, before parsing. Compare the whole
sha256=<hex>string. Send a test delivery after every rotation
A delivery is recorded as failed with no response status
- Cause
- We never got a response — DNS did not resolve, the connection was refused, the TLS handshake failed, or the endpoint took longer than ten seconds
- What to do
- Check the hostname resolves publicly and the certificate chain is complete, then make the handler answer immediately and queue the work. Replay the failed deliveries once it does
Deliveries fail with 301 or 302 recorded
- Cause
- The endpoint redirects, and redirects are not followed
- What to do
- Register the destination URL directly
The endpoint works when tested by hand but fails every real delivery
- Cause
- The hostname resolves to a private address from inside the platform — a split-horizon DNS entry, or a record pointing into a VPC
- What to do
- Publish a record that resolves to a routable public address for the receiver
A test delivery succeeds but real events never arrive
- Cause
- The webhook is paused, or its
eventslist is empty or missing the type you are waiting for — a test ignores both, on purpose - What to do
- Set the webhook active and set the full
eventslist, then trigger a real event to confirm
A replayed delivery goes somewhere unexpected
- Cause
- Replay uses the webhook's current endpoint and secret, not the ones in force when the original was sent
- What to do
- Check the endpoint before replaying after an edit, and replay only after the receiver you intend to reach is the one configured
Examples
A Node receiver that verifies and acknowledges
const express = require("express");const crypto = require("crypto"); const app = express();const secret = process.env.GITMOAP_WEBHOOK_SECRET; app.post("/gitmoap", express.raw({ type: "application/json" }), (req, res) => { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex"); const received = req.get("X-Gitmoap-Signature-256") || ""; const a = Buffer.from(expected); const b = Buffer.from(received); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).send("bad signature"); } // Acknowledge inside the ten-second budget, then do the work. res.status(202).send("ok"); const delivery = req.get("X-Gitmoap-Delivery"); const event = req.get("X-Gitmoap-Event"); const payload = JSON.parse(req.body.toString("utf8")); console.log(delivery, event, payload.repository && payload.repository.full_path);}); app.listen(8080);express.raw matters: it hands you the untouched bytes. express.json would parse first, and a signature computed over the parsed-then-reserialised object does not match ours.
A Python receiver
import hashlibimport hmacimport os from flask import Flask, request app = Flask(__name__)SECRET = os.environ["GITMOAP_WEBHOOK_SECRET"].encode() @app.post("/gitmoap")def receive(): body = request.get_data() expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Gitmoap-Signature-256", "")): return "bad signature", 401 print(request.headers["X-Gitmoap-Delivery"], request.headers["X-Gitmoap-Event"]) return "", 202Replaying everything that failed while a receiver was down
gitmoap webhooks list-deliveries --repo-id acme/platform/api-gateway --webhook-id <webhook-id> --all --output json \ | jq -r '.items[] | select(.success == false) | .delivery_id' \ | while read -r delivery; do gitmoap webhooks redeliver --repo-id acme/platform/api-gateway --webhook-id <webhook-id> --delivery-id "$delivery" doneReplace <webhook-id> with the id from gitmoap webhooks list. --all follows the pagination cursor and merges every page into one items array, so the loop covers the whole history rather than the newest fifty. Fix the receiver first: a replay against a broken endpoint just adds a second failed delivery for each of the first.
FAQ
Can I read a webhook's secret back?
No. It is write-only from the moment you create it — no listing, detail view or export includes it. If you have lost it, set a new one.
Does a paused webhook queue events and deliver them later?
No. Events raised while a webhook is inactive are not held anywhere, and switching it on delivers nothing retrospectively.
Which events can a webhook subscribe to?
The five in the table above: push, merge_request, issue, access and tag_push — of which the first four are what a delivery ever carries, tags arriving on push. An access payload carries the change's own details under detail, and names where it happened under scope; a group's webhook hears every change inside the group. Everything else the platform does — releases, pipeline runs, deployments — is visible through its own listings and through the activity feed rather than through a webhook subscription.
Do you sign with a timestamp, so I can reject replays?
No — the signature covers the body only. Deduplicating on X-Gitmoap-Delivery collapses the retries inside one delivery, which is the case where your endpoint did the work and then failed to answer in time. It does not collapse everything: a replay you ask for gets a fresh id, and so does a rare repeat from inside the platform, so key on the content you act on as well. Only a push payload states the event's own time, in pushed_at.
Why did my webhook stop firing after I edited it?
An events list replaces rather than merges. If an edit sent only ["push"], the merge-request and tag subscriptions were dropped by that same edit. Set the full list you want.
Can two webhooks on the same scope point at the same URL?
Yes, and both fire. That is occasionally useful — different secrets for different consumers behind one gateway — but it is a common cause of accidental duplicates.
Related
- Access tokensminting a token that carries
webhook:readorwebhook:manage, and revoking it - Groupsthe nesting that decides how far a group webhook reaches, and the usage view showing your live count against the webhook limit
- Pipelineswhen you want the platform to run something on a push rather than tell an external system about it
- Audit logwho created, edited or deleted a webhook, and when
- Merge requests and work itemsthe state changes behind the
merge_requestandissuepayloads - Repositoriesthe ids and paths every webhook call takes, and the settings a Maintainer reaches them from