Back to blog

Testing Your Webhook Handler in CI

webhookstutorialsengineering

Most webhook tests start with a saved payload. You load a fixture, send it to your handler, and check the response. That is a useful test, and you should keep it.

But it only tests the part of the request you recreated.

A real provider sends the exact bytes it signed, over TLS, with its own headers. The request may pass through a proxy before your framework parses the body. Any one of those details can break a webhook handler while every fixture test continues to pass.

The best way to catch those problems is to let a real webhook reach your application during the build.

The Basic Idea

Your CI runner has the same problem as your laptop: an application is listening on localhost, but the outside world cannot reach it. The Hookbridge CLI solves that by opening an outbound connection to Hookbridge and forwarding webhooks from a public receive URL to your local port.

In CI, that endpoint should only live as long as the job does. The flow looks like this:

  1. Create a temporary endpoint.
  2. Start a listener that forwards webhooks to the application on the runner.
  3. Send test webhooks to the public receive URL.
  4. Delete the endpoint when the job finishes.

The endpoint also gets a time to live, or TTL. If the runner crashes or the job is cancelled before cleanup runs, the endpoint still expires on its own.

Set It Up with GitHub Actions

On GitHub Actions, hookbridge/hookbridge-action handles most of that setup. It downloads a checksum-verified version of the Hookbridge CLI, creates a temporary endpoint, starts the listener, and gives later steps the receive URL.

Here is a complete example:

name: Webhook tests

on: [pull_request]

jobs:
  webhook-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start the app and wait for it to be healthy
        run: |
          nohup ./my-app >/dev/null 2>&1 &
          for _ in $(seq 1 50); do
            curl -sf http://localhost:3000/health >/dev/null 2>&1 && exit 0
            sleep 0.2
          done
          echo "app did not become healthy in time" >&2
          exit 1

      - name: Create a temporary webhook endpoint
        uses: hookbridge/hookbridge-action@v1
        id: hookbridge
        with:
          api-key: ${{ secrets.HOOKBRIDGE_API_KEY }}
          port: "3000"

      - name: Send a webhook and wait for it to arrive
        env:
          RECEIVE_URL: ${{ steps.hookbridge.outputs.url }}
        run: |
          printf 'url = "%s"\n' "$RECEIVE_URL" | curl -sf -X POST \
            -H "Content-Type: application/json" \
            --data '{"hello":"world"}' \
            -K -
          ./wait-for-my-app-to-receive-it.sh

      - name: Delete the temporary endpoint
        uses: hookbridge/hookbridge-action/cleanup@v1
        if: always()
        with:
          api-key:           ${{ secrets.HOOKBRIDGE_API_KEY }}
          endpoint-id:       ${{ steps.hookbridge.outputs.endpoint-id }}
          listener-pid:      ${{ steps.hookbridge.outputs.listener-pid }}
          listener-identity: ${{ steps.hookbridge.outputs.listener-identity }}

Replace ./my-app, the health URL, and the waiting script with whatever starts and checks your application. If your webhook route is not at the root, use a full forwarding URL instead of port:

        with:
          api-key: ${{ secrets.HOOKBRIDGE_API_KEY }}
          forward: "http://localhost:3000/webhooks/stripe"

The action also accepts ttl-minutes, which defaults to 30 minutes, and name if you want to choose the endpoint name. You can override cli-version as well. If you do, pass the action’s cli-version output to the cleanup step so both steps use the same build.

Wait for the Webhook to Arrive

Sending a webhook and checking your application on the very next line is a race you will eventually lose.

Hookbridge replies to the sender as soon as it has stored the webhook, with a 202 by default. Forwarding to the listener and on to your application happens afterwards. A successful curl therefore tells you that Hookbridge accepted the webhook, not that your application has finished handling it.

In a fast CI job that gap is usually a few milliseconds, which is what makes it awkward. The test passes on your machine and on most builds, then fails once a fortnight with no useful error.

Write the check as a poll with a timeout instead of a single assertion:

#!/usr/bin/env bash
# wait-for-my-app-to-receive-it.sh
set -euo pipefail

deadline=$(( SECONDS + 15 ))
until ./check-my-app-state.sh; do
  if (( SECONDS >= deadline )); then
    echo "the webhook did not reach the application within 15 seconds" >&2
    exit 1
  fi
  sleep 0.2
done

The timeout is what turns a hang into a readable failure. If you run the CLI yourself, the listener’s JSON output gives you a more direct signal than your application’s state, and the last section of this post shows what to look for.

Why Cleanup Is a Separate Step

You may wonder why the action does not clean up after itself. It is a composite GitHub Action, and composite actions do not have a post: hook. If cleanup were simply added to the end of the action, it would run immediately after the listener started, before your test step had a chance to use it.

That is why cleanup is a second action at the end of the job.

The if: always() line is important. Without it, GitHub skips cleanup whenever an earlier step fails. That is exactly when cleanup is easiest to miss.

The TTL is the backup plan. It removes the endpoint if the job is cancelled, the runner disappears, or cleanup cannot run.

Keep the Receive URL Out of Your Logs

The action’s url output contains a secret path component. Anyone with the full URL can send a webhook to that endpoint, so treat it like a credential.

The action masks the URL in GitHub Actions logs, but it is still worth being careful with how you pass it around. In the example above, the URL enters the step through env: and is passed to curl through standard input with -K -. That keeps it off the command line, where other processes on a Linux runner may be able to read it through ps.

Do not print the URL, save it as an artifact, or post it in a pull request comment.

How We Use This in Hookbridge’s Own CI

We use the same action to test Hookbridge’s outbound delivery path. It is a little different from the earlier example because the code under test sends the webhook rather than receives it.

The job starts PostgreSQL and the local services the worker needs, runs the database migrations, and then creates a temporary endpoint with a five-minute TTL:

      - name: Create a temporary endpoint and start the listener
        id: hookbridge
        uses: hookbridge/hookbridge-action@v1
        with:
          api-key: ${{ secrets.HOOKBRIDGE_API_KEY }}
          port: "3000"
          ttl-minutes: "5"

The test stores a payload, queues a delivery, and calls the real delivery worker. That request leaves the runner over TLS, reaches Hookbridge, and comes back through the listener to a small receiver on 127.0.0.1:3000. The receiver checks the signature and confirms that the payload is the one the test sent.

Those local stand-ins cover the storage and queueing around the worker, but they cannot test the delivery path itself. This test also exercises the real HTTP client, TLS connection, redirect policy, signing-key decryption, and signature generation.

We also make the test fail if its CI configuration is missing:

          # A skipped Go test exits 0. Make missing CI configuration fail instead.
          HB_E2E_REQUIRED: "1"

That small check matters. Integration tests often skip when an environment variable is missing, which is convenient on a developer’s machine. In CI, though, a misspelled variable can turn the test into a green build that never sent anything.

Use the CLI in Other CI Systems

The GitHub Action is a wrapper around the Hookbridge CLI, so the same approach works in GitLab CI, CircleCI, Jenkins, or any other system that can run a shell script.

Set HB_API_KEY, create an ephemeral endpoint, and install the cleanup trap before running the tests:

#!/usr/bin/env bash
set -euo pipefail

export HB_API_KEY="$HOOKBRIDGE_API_KEY"

ENDPOINT_ID=""
LISTENER_PID=""

cleanup() {
  [ -n "$LISTENER_PID" ] && kill "$LISTENER_PID" 2>/dev/null || true
  [ -n "$ENDPOINT_ID" ] && hb --json endpoints delete "$ENDPOINT_ID" --force >/dev/null || true
}
trap cleanup EXIT

created=$(hb --json endpoints create --ephemeral --ttl-minutes 30)
ENDPOINT_ID=$(jq -r .id <<<"$created")
RECEIVE_URL=$(jq -r .receive_url <<<"$created")
export RECEIVE_URL

hb --json listen --endpoint "$ENDPOINT_ID" --port 3000 > listen.log &
LISTENER_PID=$!

deadline=$(( SECONDS + 30 ))
until grep -q '"event":"ready"' listen.log; do
  if (( SECONDS >= deadline )); then
    echo "listener did not report ready within 30 seconds" >&2
    cat listen.log >&2
    exit 1
  fi
  sleep 0.2
done

./run-my-webhook-tests.sh   # Reads RECEIVE_URL from the environment

There are four small details here that are easy to overlook.

First, the cleanup trap is installed before the endpoint is created. With set -e, the script exits as soon as a command fails. Cleanup written at the bottom of the script will never run after a failed test.

Second, the readiness loop has a deadline. Without one, the job can sit there forever if the listener exits before it writes the ready event.

Third, set -e makes the script return a failure when the test fails. Otherwise you can end up with the worst possible result: cleanup succeeds, the script exits with zero, and the build goes green even though the webhook test failed.

Fourth, RECEIVE_URL is exported. The test script runs as a separate process, so a plain shell variable is invisible to it. Exporting is also the right way to hand the URL over, because it keeps the secret off the command line for the reason described earlier. Note that it is exported on its own line rather than as export RECEIVE_URL=$(...), which would hide a jq failure behind the exit status of export.

Read the Listener’s JSON Output

With the global --json flag, hb listen writes newline-delimited JSON to standard output. The first line tells you the listener is ready:

{"event":"ready","endpoint_id":"01a002bd-d4ce-710b-b817-a757e78d0a16","forward_to":"http://localhost:3000"}

After each webhook, it writes another line:

{"event":"webhook","id":"01a002bd-e297-7dae-bc8d-0f63ed59ce00","content_type":"application/json","size_bytes":56,"received_at":"2026-08-15T00:06:34.222064Z","forwarded":true,"status_code":200,"latency_ms":3}

When forwarding succeeds, forwarded is true and the event includes status_code and latency_ms. If the local request fails, forwarded is false and the event contains an error instead. With --no-forward, it is also false, but there is no error because the CLI never tried to forward the request.

That distinction matters if your test reads the event stream. Do not assume every forwarded: false event is a failed request.

This stream is also the most reliable thing to wait on. Rather than poll your application’s state, poll listen.log until a webhook event appears with forwarded set to true, which tells you the delivery completed and gives you the status code your handler returned.

Handle Pull Requests Without Secrets

GitHub does not pass repository secrets to workflows triggered by pull requests from forks. In those runs, secrets.HOOKBRIDGE_API_KEY is empty and the action cannot create an endpoint.

You can skip fork and Dependabot runs cleanly with one condition:

    if: >-
      github.actor != 'dependabot[bot]' &&
      (github.event_name == 'workflow_dispatch' ||
       github.event.pull_request.head.repo.full_name == github.repository)

The repository check handles forks. The actor check handles Dependabot, whose branches are in your repository but whose workflows use a separate, restricted secret store.

Finally, create a separate Hookbridge project for CI and use an API key from that project. Hookbridge API keys have full access to their project, so a CI key should not share a project with production endpoints or production webhook data.

What This Test Actually Proves

A fixture test tells you that your handler still understands a payload you already know about. A live CI test tells you that a request can travel over the network, reach the code on your runner, and survive all the headers, proxies, body parsing, and signature checks along the way.

You do not need to replace your unit tests. Add one focused end-to-end test for the path that matters most, give its endpoint a short TTL, and clean it up when the job finishes.

For every available input and output, see the GitHub Action guide. For GitLab CI, CircleCI, Jenkins, and other systems, see Testing webhooks in CI.