Skip to main content
Webhooks, explained

How Webhooks Work Rebuild GitLab Webhooks, Then Scale Them

A webhook is one HTTP POST your server sends when something happens. That is the whole idea. This page rebuilds GitLab's push webhook in about 40 lines so you can see every moving part, then scales it up to what GitLab.com actually faces. If GitLab.com ran all its webhooks on Hook0, one label-routed event would reach the right subscribers at the repository, group, and instance level, with no custom fan-out code.

Trusted by teams at

Coinbase Eudonet GEODIS WoodWing Optery Alteos ActiveAnts Apizee

The Short Answer

A webhook is one HTTP POST a provider sends the moment an event happens on their side, like a new commit, a paid invoice, or a closed merge request. You register a URL, and instead of polling for changes you receive a JSON payload the instant the event exists. GitLab, Stripe, GitHub, and Shopify all deliver this way. Sending that POST takes about 40 lines; the retries with backoff, HMAC signatures, idempotency, delivery logs, and multitenant routing are the rest of the job. On Hook0 you publish an event once with labels, and every subscription whose labels match receives it, so a single push reaches repository, group, and instance subscribers with no custom fan-out code.

The idea

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 it

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.

1

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.

2

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.

3

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.

The hard 20%

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.

Now scale it

One Webhook Is Easy. GitLab.com Has Millions.

The 40-line version fires for one repository. GitLab.com runs webhooks for millions of projects, and it lets you attach them at three levels of the org tree.

Project (repository) webhooks

Attach a webhook to a single repository and it fires for events in that project only. This is the level most integrations use, and the one the code above rebuilds.

Group webhooks

Attach a webhook to a group or subgroup and it fires for every project inside it, including projects added later. One endpoint covers a whole namespace without touching each repository.

System hooks (instance)

An instance admin registers a system hook that fires across the entire GitLab install: pushes, new projects, new users. It sits above every group and every project.

One push to one repository can need three deliveries at once: the project's own webhook, its parent group's webhook, and an instance system hook. In GitLab's source these are distinct hook types (project, group, and system) that share one delivery path. Build that yourself and every event walks the org tree, looks up the hooks at each level, and enqueues a job per hook, multiplied by millions of tenants. That routing layer is the real cost of multitenant webhooks.

The Hook0 way

GitLab.com on Hook0: Publish Once, Route by Label

Hook0 turns that routing layer into labels. You publish each event once, tagged with its position in the org tree. Subscribers register at the level they care about, and the label match delivers to all of them.

Publish the push once, tagged with its full path

curl -X POST https://app.hook0.com/api/v1/event \
  -H "Authorization: Bearer $HOOK0_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "gitlab.push",
    "payload": { "ref": "refs/heads/main", "checkout_sha": "a1b2c3" },
    "labels": {
      "instance": "gitlab.com",
      "namespace_id": "9970",
      "project_id": "278964",
      "plan": "ultimate"
    }
  }'

Labels are up to ten key/value pairs on the event. Here they carry the full path: the instance, the group (namespace_id), the repository (project_id), and the plan.

A subscription receives an event when the event carries every label the subscription lists. Fewer labels means a broader subscription, so the same push reaches all three levels at once.

Subscriber Label filter What it receives
A repository CI hook project_id: "278964" Events from that one repository
A group audit sink namespace_id: "9970" Every event from every project in the group
An instance system hook instance: "gitlab.com" Every event on the instance

Why this is the whole point

The label match does the hierarchy for you. There is no per-tenant subscription table to keep in sync, no fan-out worker, and no routing code. Each subscriber sees only the events whose labels it asked for, so tenants stay isolated by construction. And every delivery gets the production half from the section above: retries with backoff, HMAC signatures, a delivery log, and replay, per subscriber.

Read the multi-tenant routing guide
The gap

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
Multitenant routing Custom fan-out per org level Publish once, match by label
Onboarding a subscriber Edit code, redeploy Self-serve portal and API
Skip the plumbing

Hook0 Is the Production Half, as a Service

You just saw the 40 lines that fire a webhook, the six problems that turn them into a six-month project, and how labels route one event across a whole org tree. Hook0 is an open-source webhook platform that owns all of it: you POST an event once with its labels, and Hook0 signs it, retries it on a schedule you set, logs every attempt, matches it to the right subscribers, and gives them 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" },
    "labels": { "project_id": "278964" }
  }'

Retries, HMAC signatures, delivery logs, label routing, and subscriber notification are handled for you.

FAQ

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 project and group webhooks in GitLab?

A project webhook fires for events in one repository. A group webhook fires for every project inside a group or subgroup, including projects added later. GitLab also has instance-level system hooks for admins. The three levels let a single push reach subscribers at the repository, group, and instance at once.

How do you build multitenant webhooks?

Tag every event with labels that describe the tenant, then let subscriptions filter on those labels. On Hook0 an event carries up to ten key/value labels, and a subscription receives it only when the event contains all the labels the subscription lists. That gives you per-tenant isolation and hierarchical routing without a custom fan-out layer.

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, multitenant routing, 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.

No credit card required
Setup in 5 minutes
Cancel anytime