How Webhooks Work Rebuild GitLab Webhooks to Find Out
A webhook is one HTTP POST your server sends when something happens. That is the whole idea. The fastest way to really get it is to rebuild a webhook system you already use every day, so this page rebuilds GitLab's push webhook in about 40 lines, then shows what a production system adds on top.
A Webhook Is a Reverse API Call
With a normal API, your code calls someone else and waits. With a webhook, they call you.
You register a URL with a provider. When an event fires on their side, a new commit, a paid invoice, a closed merge request, they send an HTTP POST to that URL with a JSON body describing what happened. No polling, no waiting, no cron job hammering an endpoint every 30 seconds asking "anything new yet?". The event pushes itself to you the moment it exists. GitLab, Stripe, GitHub, and Shopify all work this way. Under the hood it is the same three moves every time: an event happens, the provider builds a payload, the provider POSTs it to your URL.
Rebuild GitLab's Push Webhook
GitLab's own webhooks are a good teacher because the mechanics are visible and small. Here is the sender, the part GitLab runs when you push.
An event happens
A developer pushes commits. GitLab now has a fact to broadcast: repository X received a push on branch Y. Your job as the sender is to turn that fact into an HTTP request.
Build the payload
Serialize the event to JSON: the ref that changed, the commits, the author, the project. GitLab uses one shape per event type, so a receiver can branch on the event name and trust the fields that follow.
Sign and POST it
GitLab sets two headers a receiver checks: X-Gitlab-Event names the event ("Push Hook"), and X-Gitlab-Token carries the shared secret you configured. Then it POSTs the JSON to your subscriber URL. That request is the webhook.
The sender (what GitLab runs)
// Fire a webhook when a push happens. This is the whole core.
async function sendPushWebhook(subscriber, push) {
const payload = {
object_kind: 'push',
ref: push.ref, // refs/heads/main
checkout_sha: push.sha,
user_username: push.author,
project: { name: push.project, web_url: push.url },
commits: push.commits,
};
await fetch(subscriber.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gitlab-Event': 'Push Hook',
'X-Gitlab-Token': subscriber.secretToken,
},
body: JSON.stringify(payload),
});
}
GitLab authenticates with a plain shared token in X-Gitlab-Token, so the receiver compares strings. GitHub and Stripe instead sign the body with HMAC-SHA256 and send the digest in a header (X-Hub-Signature-256 for GitHub), which is stronger because the signature is bound to the exact bytes of the payload.
The receiver (what you run)
// Verify the token, then act on the event.
app.post('/webhooks/gitlab', (req, res) => {
const token = req.header('X-Gitlab-Token');
if (token !== process.env.WEBHOOK_SECRET) {
return res.status(401).send('bad token');
}
const event = req.header('X-Gitlab-Event'); // 'Push Hook'
if (event === 'Push Hook') {
deployBranch(req.body.ref, req.body.checkout_sha);
}
res.status(200).send('ok'); // ack fast, work async
});
That is a working webhook end to end. It is also where the easy part ends: the code above assumes the network never fails, the receiver is always up, and nobody replays an old request.
What Production Delivery Adds
The 40-line version works on a whiteboard. Real traffic breaks it in ways you only see at 3am.
Retries with backoff
The receiver returns a 500 or times out. Do you drop the event? Retry immediately and hammer a service that is already struggling? You need a retry schedule with increasing delays, a cap on attempts, and jitter so every failed delivery does not retry in lockstep.
Signatures done right
A shared token in a header leaks the moment it lands in a log. HMAC over the raw body plus a timestamp lets the receiver verify the bytes came from you and are recent, which kills replay attacks. Now you own key storage and rotation.
Idempotency
A retry means the same event can arrive twice. Send a stable event id so the receiver can dedupe, or you will double-charge a card and double-deploy a branch.
Delivery visibility
Your first integrator will ask "did my webhook fire?" on day one. You need a log of every attempt, the response code, the latency, and a button to replay a failed delivery. Building that dashboard is its own project.
Ordering and speed
Push events can arrive out of order, and a slow subscriber must not block every other delivery. That means a queue, per-subscriber concurrency, and a fast path that acks before the heavy work runs.
Subscriber management
Endpoint registration, URL validation, event-type filtering, disabling dead endpoints, and a portal your users can self-serve. None of this is the fun part, and all of it is required.
Prototype vs Production
| Concern | 40-line prototype | Production system |
|---|---|---|
| Delivery on failure | Event is lost | Retried on a backoff schedule |
| Auth | Plain shared token | HMAC signature plus timestamp |
| Duplicate events | Not handled | Stable event id for dedupe |
| Debugging | Read server logs by hand | Delivery log with replay |
| Slow subscriber | Blocks the sender | Queued, per-subscriber concurrency |
| Onboarding a subscriber | Edit code, redeploy | Self-serve portal and API |
Hook0 Is the Production Half, as a Service
You just saw the 40 lines that fire a webhook, and the six hard problems that turn those 40 lines into a six-month project. Hook0 is an open-source webhook platform that owns the hard half: you POST an event once to the API, and Hook0 signs it, retries it on a configurable schedule, logs every attempt, and gives your subscribers a portal. Self-host the SSPL code or use the EU-hosted cloud. 100 events per day are free, no credit card.
Send an event, Hook0 delivers it
curl -X POST https://app.hook0.com/api/v1/event \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "push.received",
"payload": { "ref": "refs/heads/main", "sha": "a1b2c3" }
}'
Retries, HMAC signatures, delivery logs, and subscriber notification are handled for you.
Webhook Questions, Answered
What is a webhook in simple terms?
A webhook is an automated HTTP POST that one server sends to another when a specific event happens. Instead of your app repeatedly asking an API "anything new?", the provider pushes the event to a URL you registered, the moment it occurs.
How do GitLab webhooks work?
When an event fires (a push, a merge request, a pipeline change), GitLab sends a POST to your configured URL with a JSON body. It sets X-Gitlab-Event to name the event and X-Gitlab-Token to carry the secret you set, so your endpoint can verify the request and branch on the event type.
What is the difference between a webhook and an API?
Direction. With an API, your code calls the provider and waits for a response. With a webhook, the provider calls your code when something happens. An API is pull, a webhook is push. Most integrations use both.
How do you secure a webhook endpoint?
Verify every request before you act on it. GitLab uses a shared token; GitHub and Stripe sign the raw body with HMAC-SHA256 and send the digest in a header, which also defends against replay when paired with a timestamp. Always serve the endpoint over HTTPS and return a fast 2xx to acknowledge receipt.
Why not just build webhooks yourself?
Sending the POST is 40 lines. Retries with backoff, HMAC signatures with key rotation, idempotency, delivery logs, queuing, and a subscriber portal are the other six months. A webhook platform like Hook0 gives you that half as a service, open-source and self-hostable if you want to keep it in-house.
You have better things to build
Stop building webhook infrastructure. Start shipping features. Get started in minutes.