Recipes: test a webhook in CI or from an agent

Copy-paste snippets. Each one creates a bin, triggers a webhook, and asserts on exactly what arrived — no browser, no signup. This is what Catchr is built for.

The 3-step pattern

  1. Create a bin → get a url + token.
  2. Point your webhook / callback / agent tool at url.
  3. Read /api/bins/:id/requests?token=… and assert on the payload.

🐚 Bash / any CI (curl + jq)

set -e
BIN=$(curl -s -X POST https://catchr.perch-app.workers.dev/api/bins)
ID=$(echo "$BIN" | jq -r .id); TOKEN=$(echo "$BIN" | jq -r .token)
URL=$(echo "$BIN" | jq -r .url)

# ... trigger whatever fires a webhook at "$URL" ...
curl -s "$URL" -H 'content-type: application/json' -d '{"event":"deploy.finished"}'

# assert it arrived with the right body
BODY=$(curl -s "https://catchr.perch-app.workers.dev/api/bins/$ID/requests?token=$TOKEN" | jq -r '.requests[0].body')
echo "$BODY" | jq -e '.event == "deploy.finished"' > /dev/null \
  && echo "✅ webhook received" || { echo "❌ missing webhook"; exit 1; }

🐍 Python (pytest)

import requests, time

BASE = "https://catchr.perch-app.workers.dev"

def test_my_webhook_fires():
    bin = requests.post(f"{BASE}/api/bins").json()
    url, id_, token = bin["url"], bin["id"], bin["token"]

    # trigger the thing under test that should POST to `url`
    my_app.send_notification(webhook_url=url, event="user.created")

    # long-poll: one blocking call returns the instant the webhook lands
    reqs = requests.get(f"{BASE}/api/bins/{id_}/requests",
                        params={"token": token, "wait": 15}).json()["requests"]

    assert reqs, "no webhook received"
    import json
    assert json.loads(reqs[0]["body"])["event"] == "user.created"

The wait=15 long-poll replaces the usual sleep/retry loop — the call returns as soon as the request arrives (or empty after the timeout).

🤖 AI agent tool-callback

Testing that your agent's tool actually calls back the URL you gave it? Give it a Catchr bin and inspect the real request it made — headers, auth token, body and all.

bin = requests.post("https://catchr.perch-app.workers.dev/api/bins").json()
agent.run(tools=[Webhook(url=bin["url"])], task="notify me when done")

reqs = requests.get(f'https://catchr.perch-app.workers.dev/api/bins/{bin["id"]}/requests',
                    params={"token": bin["token"]}).json()["requests"]
print(reqs[0]["headers"])   # did it send the auth header you expected?
print(reqs[0]["body"])      # did it send the payload you expected?

🧪 Node (Vitest / Jest)

const BASE = "https://catchr.perch-app.workers.dev";
test("stripe webhook is forwarded", async () => {
  const bin = await (await fetch(BASE + "/api/bins", {method:"POST"})).json();
  await triggerCheckout(bin.url);              // your code under test
  const { requests } = await (await fetch(
    `${BASE}/api/bins/${bin.id}/requests?token=${bin.token}`)).json();
  expect(requests.length).toBeGreaterThan(0);
  expect(JSON.parse(requests[0].body).type).toBe("checkout.session.completed");
});

⚙️ GitHub Actions step

- name: Verify webhook delivery
  run: |
    BIN=$(curl -s -X POST https://catchr.perch-app.workers.dev/api/bins)
    ID=$(echo "$BIN" | jq -r .id); TOK=$(echo "$BIN" | jq -r .token)
    URL=$(echo "$BIN" | jq -r .url)
    ./scripts/deploy.sh --notify-url "$URL"
    # long-poll up to 20s instead of a fixed sleep — returns the moment it lands
    N=$(curl -s "https://catchr.perch-app.workers.dev/api/bins/$ID/requests?token=$TOK&wait=20" | jq '.count')
    test "$N" -ge 1 || { echo "no webhook fired"; exit 1; }

More endpoints in the API docs. Machine-readable: /openapi.json · /llms.txt.