Skip to content

Testing Webhooks in CI

Webhook-dependent code is hard to test without a real endpoint to receive against. Running that test suite in CI means giving each run somewhere to receive webhooks — without leaving a permanent endpoint lying around, and without different CI runs colliding with each other’s deliveries.

The pattern that solves this: create an endpoint scoped to a single CI run, forward its deliveries to the application running in that job, and delete the endpoint when the job finishes. If cleanup never runs — a cancelled job, a crashed runner — the endpoint’s TTL deletes it anyway.

  1. Create an ephemeral endpoint with a TTL, so it expires on its own even if cleanup fails.
  2. Start a listener that forwards deliveries from that endpoint to the app under test in the job.
  3. Point whatever is sending webhooks — a real provider in a staging setup, or your own test script — at the endpoint’s receive URL.
  4. Run the tests.
  5. Delete the endpoint explicitly. The TTL is a backstop, not the primary cleanup path.

The GitHub Action implements this whole pattern as two steps — one to create the endpoint and start the listener, one to clean up:

- uses: hookbridge/hookbridge-action@v1
id: hookbridge
with:
api-key: ${{ secrets.HOOKBRIDGE_API_KEY }}
- run: ./run-my-webhook-tests.sh
env:
WEBHOOK_URL: ${{ steps.hookbridge.outputs.url }}
- 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 }}

See the GitHub Action reference for inputs, outputs, and the fork-pull-request caveat covered again below.

There is no action for GitLab CI, CircleCI, Jenkins, or similar systems — the pattern is the same, built directly out of hb commands. HB_API_KEY alone is enough to authenticate; there is no need to run hb login in a CI job. This example uses bash-only features (trap, $SECONDS, arithmetic conditionals), so run it with bash, not sh.

#!/usr/bin/env bash
set -euo pipefail
export HB_API_KEY="..." # from your CI system's secret store
endpoint_id=""
listener_pid=""
cleanup() {
local status=$?
if [ -n "$listener_pid" ]; then
kill "$listener_pid" 2>/dev/null || true
fi
if [ -n "$endpoint_id" ]; then
hb endpoints delete "$endpoint_id" --force || true
fi
exit "$status"
}
trap cleanup EXIT
# 1. Create the endpoint
create_output=$(hb --json endpoints create --ephemeral --ttl-minutes 30)
endpoint_id=$(echo "$create_output" | jq -r '.id')
receive_url=$(echo "$create_output" | jq -r '.receive_url')
# 2. Start the listener in the background, wait for it to be ready
hb --json listen --endpoint "$endpoint_id" --port 3000 > listen.log 2>listen-diagnostics.log &
listener_pid=$!
ready_timeout=60
start=$SECONDS
ready=0
reason="timed out after ${ready_timeout}s waiting for hb listen to report ready"
while (( SECONDS - start < ready_timeout )); do
if grep -q '"event":"ready"' listen.log 2>/dev/null; then
ready=1
break
fi
if ! kill -0 "$listener_pid" 2>/dev/null; then
reason="hb listen exited before reporting ready"
break
fi
sleep 0.5
done
if [ "$ready" -ne 1 ]; then
echo "error: $reason" >&2
echo "--- listen-diagnostics.log ---" >&2
cat listen-diagnostics.log >&2 2>/dev/null || true
exit 1
fi
# 3. Run the tests against the receive URL
WEBHOOK_URL="$receive_url" ./run-my-webhook-tests.sh

A few notes on this script:

  • hb --json endpoints create prints one JSON object to stdout with id and receive_urljq -r pulls each field out.
  • hb --json listen streams one JSON object per line to stdout, and the ready event is always the first line — but hb listen can also exit before ever printing it (a bad endpoint ID, an auth failure, a transient API error). The wait loop polls the log for the ready line, and also checks with kill -0 whether the listener process has died. The overall timeout is what actually guarantees the script doesn’t hang, though: bash doesn’t deterministically reap a background process in a non-interactive script, so kill -0 can briefly still report a dead listener as alive. Treat the liveness check as a fast path, not the safety net. Either failure prints the reason and the listener’s diagnostics log to stderr and exits non-zero.
  • trap cleanup EXIT, installed before the endpoint or listener exist, is what makes cleanup unconditional. set -euo pipefail means a failing test — or a failing hb command — aborts the script immediately, and without a trap the kill/delete commands that used to sit at the bottom of the script would simply never run. The trap fires on every exit path, each cleanup step is guarded so it’s a no-op if the endpoint or listener was never created, and it re-exits with the original status so a failing test still fails the CI job instead of being masked by a successful cleanup.
  • endpoints delete doesn’t prompt for confirmation when stdin isn’t a TTY, which is always true in CI — but passing --force explicitly is belt-and-braces if the same script ever gets run somewhere stdin is attached to a terminal.

See the CLI Command Reference for the full flag and output reference for endpoints create, listen, and endpoints delete.

  • Fork pull requests. A pull_request workflow triggered from a fork gets no access to repository secrets, so an API key secret arrives empty and endpoint creation fails. This is a platform security boundary, not something HookBridge can work around. Guard the job to skip cleanly on fork PRs (see the GitHub Action page for the exact condition), or point fork contributors at test.hookbridge.io instead, which needs no HookBridge account.
  • Use a dedicated CI project. HookBridge API keys carry no scopes and no read-only mode — a key has full access to whatever project it belongs to, including deleting endpoints and reading the payloads that pass through them. Create a separate HookBridge project for CI and use a key from that project, never your production key, so a compromised CI run can’t reach production traffic.
  • Treat the receive URL as a credential. It embeds a secret path component. Don’t log it in plaintext, echo it to a public build log, or commit it anywhere.
  • Mask it in logs. Most CI systems let you mark a value as a secret so the platform masks it in job output automatically — do that for the receive URL in a CLI-only pipeline. The GitHub Action does this masking for you.
Personalize Examples

Enter your credentials to populate code examples throughout the docs.