Testing Webhooks in CI
Why You Would Use This
Section titled “Why You Would Use This”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.
The Throwaway-Endpoint Pattern
Section titled “The Throwaway-Endpoint Pattern”- Create an ephemeral endpoint with a TTL, so it expires on its own even if cleanup fails.
- Start a listener that forwards deliveries from that endpoint to the app under test in the job.
- Point whatever is sending webhooks — a real provider in a staging setup, or your own test script — at the endpoint’s receive URL.
- Run the tests.
- Delete the endpoint explicitly. The TTL is a backstop, not the primary cleanup path.
On GitHub Actions
Section titled “On GitHub Actions”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.
On Other CI Systems
Section titled “On Other CI Systems”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 bashset -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 endpointcreate_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 readyhb --json listen --endpoint "$endpoint_id" --port 3000 > listen.log 2>listen-diagnostics.log &listener_pid=$!
ready_timeout=60start=$SECONDSready=0reason="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.5done
if [ "$ready" -ne 1 ]; then echo "error: $reason" >&2 echo "--- listen-diagnostics.log ---" >&2 cat listen-diagnostics.log >&2 2>/dev/null || true exit 1fi
# 3. Run the tests against the receive URLWEBHOOK_URL="$receive_url" ./run-my-webhook-tests.shA few notes on this script:
hb --json endpoints createprints one JSON object to stdout withidandreceive_url—jq -rpulls each field out.hb --json listenstreams one JSON object per line to stdout, and thereadyevent is always the first line — buthb listencan also exit before ever printing it (a bad endpoint ID, an auth failure, a transient API error). The wait loop polls the log for thereadyline, and also checks withkill -0whether 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, sokill -0can 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 pipefailmeans a failing test — or a failinghbcommand — 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 deletedoesn’t prompt for confirmation when stdin isn’t a TTY, which is always true in CI — but passing--forceexplicitly 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.
Operational Tips
Section titled “Operational Tips”- Fork pull requests. A
pull_requestworkflow 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.
Next Steps
Section titled “Next Steps”- GitHub Action — the packaged version of this pattern for GitHub Actions
- CLI Command Reference — full flag and output reference
Enter your credentials to populate code examples throughout the docs.