Testing webhooks in CI

Ephemeral endpoints that clean up after themselves, and an await API that turns 'did the webhook fire?' into one blocking HTTP call.

The hard part of testing webhooks has never been sending them — it's asserting they arrived. WebhookVault's CI mode makes that one HTTP call: create a self-expiring endpoint, point the system under test at it, trigger the action, and block until the webhook lands (or the timeout says it didn't).

The pattern

# 1. An endpoint that deletes itself in 15 minutes — no teardown step to forget.
EP=$(curl -s https://webhookvault.dev/api/v1/endpoints \
  -H "Authorization: Bearer $WV_KEY" -H "Content-Type: application/json" \
  -d '{"name":"ci-'"$CI_JOB_ID"'","ttlSeconds":900}')

EP_ID=$(echo "$EP"  | jq -r .id)
EP_URL=$(echo "$EP" | jq -r .url)

# 2. Point the system under test at $EP_URL and trigger the action.
./configure-webhook-target "$EP_URL"
./trigger-the-thing

# 3. Block until the webhook arrives — or fail the job if it doesn't.
RESULT=$(curl -s -w '\n%{http_code}' \
  "https://webhookvault.dev/api/v1/endpoints/$EP_ID/requests/await?q=order.created&timeoutSeconds=60" \
  -H "Authorization: Bearer $WV_KEY")

STATUS=$(echo "$RESULT" | tail -1)
[ "$STATUS" = "200" ] || { echo "webhook never arrived"; exit 1; }

# 4. Assert on the payload.
echo "$RESULT" | head --1 | jq -e '.body | fromjson | .order.total == 4900'

How await behaves

GET /api/v1/endpoints/{id}/requests/await long-polls:

  • 200 with the request the moment something matching the filters arrives.
  • 204 when nothing matching arrived within timeoutSeconds (1–80, default 30) — retry or fail.
  • All the search filters apply: q, method, state, since, afterId.

By default only requests arriving after the call starts match. If the webhook can fire before your await begins (fast systems, slow runners), close the race with afterId: read the newest stored id first — or use afterId=0 to accept anything already stored:

LAST_ID=$(curl -s ".../requests?pageSize=1" -H "Authorization: Bearer $WV_KEY" \
  | jq -r '.items[0].id // 0')
./trigger-the-thing
curl -s ".../requests/await?afterId=$LAST_ID&timeoutSeconds=60" -H "Authorization: Bearer $WV_KEY"

There is no polling under the hood — the call parks server-side and wakes on arrival, so matches return in milliseconds, not at the next poll interval.

GitHub Actions example

jobs:
  webhook-contract:
    runs-on: ubuntu-latest
    steps:
      - name: Create ephemeral endpoint
        run: |
          EP=$(curl -s https://webhookvault.dev/api/v1/endpoints \
            -H "Authorization: Bearer ${{ secrets.WV_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"name":"gh-${{ github.run_id }}","ttlSeconds":900}')
          echo "EP_ID=$(echo "$EP" | jq -r .id)"   >> "$GITHUB_ENV"
          echo "EP_URL=$(echo "$EP" | jq -r .url)" >> "$GITHUB_ENV"

      - name: Trigger the system under test
        run: ./scripts/fire-order-created "$EP_URL"

      - name: Assert the webhook arrived
        run: |
          BODY=$(curl -s -f \
            "https://webhookvault.dev/api/v1/endpoints/$EP_ID/requests/await?q=order.created&timeoutSeconds=60" \
            -H "Authorization: Bearer ${{ secrets.WV_KEY }}") \
            || { echo "::error::webhook never arrived"; exit 1; }
          echo "$BODY" | jq -e '.delivery and (.body | fromjson | .type == "order.created")'

The endpoint expires on its own — a cancelled job leaves nothing behind.

Where next?

On this page