Skip to content
GitmoapdocsBeta
Guide· How do I…?

Webhooks

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

Quick start3 steps

Register your first webhook

You’ll need Maintainer on the repository or the group, and an HTTPS endpoint that is already deployed.
  1. Choose Settings, then Webhooks, then New webhook.

  2. Paste the endpoint's URL, set a signing secret you have stored, and tick the events you care about.

  3. 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:read to list a scope's webhooks and browse deliveries, webhook:manage to 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 carry webhook:read alone. Add repo:read or group:read if you want to pass a path such as acme/platform/api-gateway to 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 are localhost, 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 32 is 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 2xx first 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 → fires

A 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 typeFires whenaction on the payload
pusha git push updates one or more refs in the repositorynone — the payload carries a ref_updates array, one entry per updated ref with its before and after commit
merge_requesta merge request is opened, edited, merged, closed or reopenedopened, updated, merged, closed, reopened
issuea work item changesopened, updated, status_changed, closed, reopened, moved, reparented, assigned, unassigned, commented, linked_to_commit, linked_to_merge_request, verified, regressed
tag_pushnever — 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—
accesswho 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 deletedgranted, 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 typeThe subject on the body
pushref_updates — an array of { ref_name, before, after }, one per updated ref — and pushed_at
merge_requestmerge_request — { id, title, status, source_branch, target_branch }
issueissue — { 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:

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

HeaderValue
X-Gitmoap-Eventthe event type the delivery carries — push, merge_request or issue
X-Gitmoap-Deliverythe id of this delivery — every attempt at it, the first and any retry, carries the same value
X-Gitmoap-Signature-256sha256= followed by the hex HMAC-SHA256 of the exact body bytes, keyed with your secret
Content-Typeapplication/json
User-AgentGitmoap-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 doesWhat we do
answers 2xxsuccess — recorded, no retry
answers 4xxfailure — recorded, no retry, because a rejection is a decision
answers 5xxretried, up to three attempts in total
answers nothing within ten seconds, refuses the connection, or fails to resolveretried, 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.

Terminalbash
gitmoap webhooks create \
--repo-id <id> \
--events '<json>' \
--secret <secret> \
--url <url>
curlbash
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.

Terminalbash
gitmoap webhooks create-group \
--group-id <id> \
--events '<json>' \
--secret <secret> \
--url <url>
curlbash
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.

  1. 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.

  2. Compute HMAC-SHA256(secret, body) and hex-encode it.

  3. Prefix it with sha256= and compare it against X-Gitmoap-Signature-256 using a constant-time comparison.

  4. Reject with 401 when 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.

Terminalbash
gitmoap webhooks list --repo-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks list-group --group-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks get --repo-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks get-group --group-id <id> --webhook-id <id>
curlbash
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.

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

Terminalbash
gitmoap webhooks test --repo-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks test-group --group-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks list-deliveries --repo-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks list-group-deliveries --group-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks get-delivery \
--repo-id <id> \
--webhook-id <id> \
--delivery-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks get-group-delivery \
--group-id <id> \
--webhook-id <id> \
--delivery-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks redeliver \
--repo-id <id> \
--webhook-id <id> \
--delivery-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks redeliver-group \
--group-id <id> \
--webhook-id <id> \
--delivery-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks update --repo-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks update-group --group-id <id> --webhook-id <id>
curlbash
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.

  1. Generate a new secret — openssl rand -hex 32.

  2. Teach your receiver to accept either the old or the new signature, and deploy that.

  3. Edit the webhook and set the new secret. Send a test delivery and confirm it succeeded.

  4. 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.

Terminalbash
gitmoap webhooks delete --repo-id <id> --webhook-id <id>
curlbash
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.

Terminalbash
gitmoap webhooks delete-group --group-id <id> --webhook-id <id>
curlbash
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

create_webhookPOST /repositories/{repo_id}/webhooksOpen in the API reference

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 request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Repository not found
  • 409The new webhook collided with a stored record; nothing was created, retry
  • 429Webhook 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

list_webhooksGET /repositories/{repo_id}/webhooksOpen in the API reference

Token scope

webhook:read

Send

Path
  • repo_iduuidRepository ID

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Repository 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

get_webhookGET /repositories/{repo_id}/webhooks/{webhook_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not 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

update_webhookPATCH /repositories/{repo_id}/webhooks/{webhook_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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

delete_webhookDELETE /repositories/{repo_id}/webhooks/{webhook_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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

create_group_webhookPOST /groups/{group_id}/webhooksOpen in the API reference

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 request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Group not found
  • 409The new webhook collided with a stored record; nothing was created, retry
  • 429Webhook 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

list_group_webhooksGET /groups/{group_id}/webhooksOpen in the API reference

Token scope

webhook:read

Send

Path
  • group_iduuidGroup ID

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Group 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

get_group_webhookGET /groups/{group_id}/webhooks/{webhook_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not 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

update_group_webhookPATCH /groups/{group_id}/webhooks/{webhook_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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

delete_group_webhookDELETE /groups/{group_id}/webhooks/{webhook_id}Open in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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 reference

Token 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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Webhook 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 reference

Token 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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not 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 reference

Token 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 again
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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

test_webhookPOST /repositories/{repo_id}/webhooks/{webhook_id}/testOpen in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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 reference

Token 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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Webhook 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 reference

Token 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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not 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 reference

Token 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 again
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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

test_group_webhookPOST /groups/{group_id}/webhooks/{webhook_id}/testOpen in the API reference

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 type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found
  • 409The 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.

PlanWebhooks per repository or group
Free20
Pro50
Team200
Enterprise500

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, a 127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x or 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 events entry is not one of push, 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-request and MergeRequest are 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 secrets or event
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-repo pasted where a repository id belongs
What to do
Use the id. The CLI also takes a path for --repo-id and --group-id and looks the id up for you, which needs repo:read or group:read on 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:read for the listing and detail calls, or webhook:manage for 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 404 rather than 403 so 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 409 means
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": false rather than leaving a field out
Nothing arrives after a push, and no delivery is recorded
Cause
The webhook is inactive, or its events list does not include the event type
What to do
Open the webhook and check both. An empty events list 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 push event
What to do
Subscribe to push and look for a refs/tags/… entry in ref_updates. An all-zero before is a tag being created, an all-zero after one 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_request and act on the merged action. 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 events list 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 events list, 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

javascript
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

python
import hashlib
import hmac
import 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 "", 202

Replaying everything that failed while a receiver was down

shellscript
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"
done

Replace <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.

  • Access tokensminting a token that carries webhook:read or webhook: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_request and issue payloads
  • Repositoriesthe ids and paths every webhook call takes, and the settings a Maintainer reaches them from