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:
200with the request the moment something matching the filters arrives.204when nothing matching arrived withintimeoutSeconds(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?
awaitRequest— the full parameter referencecreateEndpoint—ttlSecondsbounds and validation- Rate limits — an await call counts as one API call, however long it waits