# Your test is sleeping. The webhook already arrived.

> A sleep is a guess about someone else's latency. Wait for the request itself: the test returns the moment the webhook lands, and fails loudly when it never does.

- Author: Quentin Mousset
- Published: 2026-08-20
- Canonical: https://repost.sh/blog/webhook-tests-without-sleep

![Three test runs on one time axis marked 0s, 0.4s, 5s and 9s. The first, sleep 5, runs the full five seconds although the webhook lands at 0.4s, and the stretch after it is hatched and labelled 4.6s of nothing. The second, sleep 5 on a slow day, ends at 5s with a coral cross reading expected 1, got 0, while the webhook only lands at 9s, too late. The third, repost expect, stops at 0.4s with a check mark reading returns on arrival, and a dashed line continues to a coral bracket: or exit 7 at the timeout.](/images/blog/figure-sleep-vs-expect.webp)

Somewhere in your integration suite there is a <code>sleep(5)</code> waiting on a webhook. It usually
  lands in 400 ms, so most runs throw away four seconds. Every so often it takes nine, and the test
  fails on <code>expected 1, got 0</code>, which tells you a row is missing and nothing about why.

That sleep is not a wait. It's a bet on latency you don't own: the provider's
queue, the network, whatever else the runner is doing that second. Those
numbers move. Your sleep doesn't.

The usual patch is to raise it. That buys a greener suite and a slower one, and
the day the webhook genuinely never arrives, the failure reads just as badly.

## Wait for the request, not the clock

`repost expect` blocks until a webhook matching your filter arrives in a
bucket, then exits the second it lands.

```console
$ repost expect --bucket acme-hooks --method POST --path '/stripe/*' --timeout 45s
{"event_id":"evt_01JZ8Y1","bucket_id":"bkt_01HV9S","method":"POST","path":"/stripe/webhook","matched_at":"2026-06-19T08:45:10Z","received_at":"2026-06-19T08:45:09Z"}
```

One line of JSON on stdout, exit `0`. The `event_id` it carries is your handle
for whatever you want to look at next, down to [the delivery
chain](/docs/agents/diagnose-webhooks).

`acme-hooks` is a bucket: the inbound endpoint your provider posts to, and the
same one your laptop reads from once you've
[dropped the tunnel](/blog/webhooks-to-localhost-without-a-tunnel).

Three ways to say which request you mean, and they stack. `--method POST`
matches the verb, case-insensitively. `--path '/stripe/*'` matches the path as
a shell glob. `--filter` takes a small field query with `AND`, `OR`, `NOT` and
parentheses over `method`, `path`, `content_type`, `event_id` and `bucket_id`.

Only `--bucket` is required. `--timeout` defaults to `30s` and takes durations
like `45s` or `2m` ([every flag](/docs/cli/reference)).

> **What the filter can see**: The live filter reads the envelope of the request: method, path, content type, ids. Not the body. A query like `request.type:charge.refunded` belongs to [indexed search](/docs/agents/search-and-filters), which is a different surface with a different grammar.

## Start the wait before you trigger

Here is the rule that will bite you once. The observe stream has [no
backfill](/docs/agents/consistency-model): `expect` sees what arrives after it
connects, and nothing before. Open it after the trigger and you're waiting on a
webhook that already came and went.

So the waiter goes first, and the thing that provokes the webhook goes second.

```bash
set -euo pipefail

# the stream has no backfill, so connect before anything can arrive
repost expect --bucket acme-hooks --method POST --path '/stripe/*' --timeout 45s > matched.json &
waiter=$!

./scripts/create-test-charge.sh

wait "$waiter"
```

`wait` returns the exit status of the background job, so the script fails when
and only when the webhook didn't show up.

Be precise about what a green `expect` proves: a matching request reached your
bucket. Whether your handler ran, and what it wrote, is still yours to assert.
What changed is that the part you couldn't control stopped being a guess.

## When it never arrives

When nothing matches before the deadline, `expect` exits `7` and writes the
reason to stderr. On a runner, stdout is a pipe rather than a terminal, so the
CLI is already in JSON mode and the failure comes out machine readable.

```console
$ repost expect --bucket acme-hooks --method POST --path '/stripe/*' --timeout 45s
{"error":{"code":"timeout","message":"condition not met before timeout","exit_code":7,"docs":"repost docs agent"}}

$ echo $?
7
```

`7` is the timeout code across the whole CLI, so a script can branch on it
without parsing English. And the failure finally names the thing that went
wrong: the request never arrived. `expected 1, got 0` only ever told you a row
was missing, several layers downstream of the reason.

## expect asserts, tail watches

`tail` is the neighbor command, and it answers a different question. It streams
matching events as NDJSON, one object per line, and stops on `--count` or
`--max-wait`. Either way it exits `0`. It reports what it saw. It doesn't judge.

One request you require: `expect`. A handful you want to look at, or a live
view while you poke at something by hand: `tail`. It has no `--path` or
`--method`, so those conditions go in `--filter`. Both commands are covered in
[Wait for events](/docs/agents/wait-for-events).

## Put it in the pull request job

```yaml
name: integration
on: pull_request

jobs:
  webhooks:
    runs-on: ubuntu-latest
    env:
      REPOST_TOKEN: ${{ secrets.REPOST_TOKEN }}
    steps:
      - uses: actions/checkout@v4

      - name: Install the Repost CLI
        run: curl -fsSL https://releases.repost.sh/cli/install.sh | sh

      - name: Wait for the provider callback
        run: ./scripts/expect-charge.sh
```

`REPOST_TOKEN` takes priority over anything stored on the machine, which is why
the same script runs on a fresh runner and on your laptop
([CI & automation](/docs/cli/ci-automation)). It's also the job where
`repost schema generate --check` belongs: [both fail the pull request instead
of the deploy](/blog/one-webhook-schema-six-sdks).

So stop timing the wait. Name the request you're waiting for, and let its
arrival be the thing that ends it.

**See it on your own events.** Open a live inbox and inspect your first webhook in seconds (50K events/month free).
