webhookjar
Performance

Tested, not promised.

We sent webhookjar.com tens of thousands of real webhooks through Cloudflare, the way Stripe and GitHub do, then counted every one in the database. Here is what it does, how fast, and where it stops.

63 of 63 checks passedLast run 29 Sep 2026, 20:35 UTC6 cores, 12 GB, PHP 8.3, SQLite
32 msto catch a webhook and answer it, median
3.8 msof that is our own work; the rest is the internet
222/sheld for 45 s straight, 19 million a day
0lost: 18,834 answered, every one stored
How it works

One webhook, start to finish.

A sender's request crosses Cloudflare, lands on our box, is read, checked and saved in one step, and answered. Timings are medians from the last run.

1

The sender connects

DNS, a connection and TLS to Cloudflare's edge nearest the sender. A sender that keeps its connection open skips this next time.

DNS 2.5 msConnect 9.7 msTLS 41 ms
2

Cloudflare passes it on

The request travels from the edge to our server and the answer travels back. This is most of what a sender waits for.

Round trip about 28 ms
3

We read, check and save

Who sent it, the event, repeats, signatures, a live key inside. Then the counter and the save happen in one write, so nothing is ever half stored.

Inside the box 3.8 ms
4

Answered, then shared

The sender gets its 200 at once. Your inbox shows it live, the API hands it to code that waits, and forwarding runs after the answer.

Readable by API 322 msForward 418 ms
Every answer carries a Server-Timing: app;dur=3.8 header, so anyone can see our part of the wait for themselves.
Speed

One at a time, how fast?

Each call sent alone and timed from the sender's side, through Cloudflare. p95 means 95 in 100 were at least this fast.

CallMedianp95p99Our part
Catch a webhookwarm connection, 150 sends
32 ms124 ms240 ms3.8 ms
Catch a webhooknew connection each time, 25 sends
108 ms355 ms407 ms3.3 ms
Health check/status.json, 50 calls
31 ms73 ms226 ms0 ms
API: usageGET /v1/usage, 50 calls
33 ms178 ms339 ms2.9 ms
API: list requestsGET /v1/inboxes/{id}/requests, 30 calls
63 ms137 ms164 ms3.1 ms
API: read oneGET /v1/requests/{id}, 30 calls
52 ms219 ms309 ms3.4 ms
Load

How much it can take.

More and more senders at once, 15 seconds each, until answers got slow. Spread over 2,500 inboxes, half anonymous and half a member's, so no per-inbox limit got in the way.

Caught a secondp95 answer time
0501001502002500 s1 s2 s3 s1 senders: 4 a second, p95 148 ms415 senders: 45 a second, p95 273 ms45510 senders: 74 a second, p95 325 ms741025 senders: 115 a second, p95 559 ms1152550 senders: 155 a second, p95 609 ms15550100 senders: 154 a second, p95 3,203 ms15410050 steady senders: 222 a second, p95 506 ms22250 steadyp95 of 3 s: where the ramp stopsSenders at once
What it means
150 to 220webhooks a second, every second, with no errors
13 to 19 milliona day, if it never stopped
380Solo accounts using their full 50,000 a day, all at once
1 writerat a time is the ceiling: SQLite saves one request after another

Past 50 senders at once, requests queue for their turn to be saved. They still land; they just wait. If one ever waits over 5 seconds, the sender is told to send again, and every provider does.

SendersA secondMedianp95Our partErrors
1437 ms148 ms4.6 ms0.8%0.8% dropped connections
54548 ms273 ms6.4 ms0%
107473 ms325 ms8.5 ms0%
25115122 ms559 ms25 ms0%
50155204 ms609 ms52 ms0%
100154357 ms3,203 ms170 ms0%p95 past 3 s, ramp stops
50 held222153 ms506 ms45 ms0%the number we plan on
Nothing lost

Every answer is a saved request.

The test gives every webhook its own event ID and counts them in the database afterwards, not just the answers.

18,834answered 200
→
18,835different events stored

The extra one is a webhook whose answer never reached the sender because the connection dropped. We had saved it, which is the right way round: a sender that hears nothing sends again, and the copy is marked as a repeat, so a handler that stores event IDs skips it.

12 sent twice the same way: the tester's own connection dropped and it resent. Both copies kept, the second one flagged.

Limits

Every limit answers the way the docs say.

Each one was pushed past its edge and counted on the server, not guessed from the outside.

120a minute, per inbox

Exactly 120 kept, the next 30 told to slow down.

429 slow_down, Retry-After: 60
300API calls a minute

Exactly 300 let through on Solo, the next told when to try again. Free has 30.

429 slow_down, Retry-After
50,000a day on Solo

The last one of the day is kept, the next is refused until midnight UTC. Free has 5,000.

429 daily_limit
500with no account

Request 500 lands, 501 is told the inbox is full.

410 inbox_full
1 MBof every body kept

A 1.44 MB body was answered 200 and its first 1 MB kept, with a note on the request.

200, body cut at 1 MB
300new inboxes an hour

Enough for a test suite that opens one per run; the next is refused for the hour.

429 slow_down
What the tests caught

Four fixes before launch.

The point of testing is finding things. Each of these was found by the load test and fixed the same night, then tested again.

29 Sep 2026
20:40Fixed
Saves now wait in a proper queueWaiting writers slept in steps of up to 100 ms, leaving the database idle between them. A queue wakes the next one the moment the last finishes.
20:27Fixed
One lock per webhook, and "busy, send again" instead of an errorEach webhook took the write lock twice. At 150 senders at once some waited over 5 seconds and got a 500. Now it is once, and a real overload answers 503 with Retry-After.
20:18Fixed
Limits are exactTwo requests at the same moment could both read the higher count, so an inbox was refused one early. The count and the add are now one step.
20:04Fixed
Repeats and members' inboxes under loadA lookup left open held an old view of the database, so the save after it failed when another webhook landed in between: 30% of big webhooks in one test. Closed straight after reading.
Every check

63 checks, all passed.

What the test does on every run, grouped as it runs.

Catching webhooks23 of 23

POST JSON
GET with a path and query
PUT
PATCH
DELETE
HEAD
Form (urlencoded)
Multipart form with a file
Binary body (4 KB)
Body over 1 MB
Body just under 1 MB
60 custom headers
Stripe event recognised
Same event again marked a repeat
GitHub event recognised
Slack URL check answered
Meta hub.challenge answered
Browser preflight (CORS)
Safe answer headers
Unknown inbox
Custom answer (202, own body)
Custom answer (500, to test retries)
Slow answer (2 s delay)

The API31 of 31

No key
Wrong key
GET /usage
POST /inboxes
GET /inboxes
GET /inboxes?kept=true
GET /inboxes/{id}
PATCH name and answer
PATCH unknown field refused
Forward to ourselves refused
GET /next?wait=0 on an empty inbox
GET /next waits for the request
GET /next?after= (one after another)
GET /requests?limit=2 (pages)
GET /requests?before= (next page)
GET /requests?provider=stripe
GET /requests/{id} (everything)
Bad limit refused
Wrong method
Unknown endpoint
Someone else's or missing inbox
POST /requests/{id}/replay
Forwarded as it arrived
Replay on Free refused
Forwarding on Free refused
Free limits in /usage
DELETE /requests/{id}
… and it is gone
DELETE /inboxes/{id}
… and its address stops
Calls logged for Uni (api_calls)

Limits7 of 7

Anonymous inbox full at 500
Expired inbox
Closed inbox
Daily allowance (Solo 50,000)
New inboxes by API (300 an hour)
One inbox: 120 a minute
API: 300 calls a minute (Solo)

Speed and load2 of 2

Sent to readable by API
Nothing lost
Room

Space and history.

1.2 KB

A typical Stripe event

Headers, body and our checks together. About 880,000 of them fit in a gigabyte.

invoice.paid, 1.2 KB stored
90 days

History on Solo

7 days on Free, 48 hours without an account. Older requests are cleared every 10 minutes.

prune: every 10 minutes
1.4 s

To clear 19,207 requests

Deleting an inbox deletes its requests at once, and the space is given back.

2,528 inboxes, 1,390 ms
How we tested

The fine print.

From where?

From our own server, out to Cloudflare and back in, the same path every sender takes. A real provider sends from its own network, so it pays its own distance to Cloudflare on top.

Why a range, not one number?

Runs vary with Cloudflare and the network from minute to minute: the best steady level moved by about a fifth between runs on the same night. So we quote a range and plan on the low end.

Is this a floor or a ceiling?

A floor. The sending side ran on the same box and used some of its CPU, and all the traffic came from one address. Real senders are spread out.

When does it run again?

Before every change that touches how webhooks are saved. This page reads the latest results, so it is never older than the code.

Send it something real.

Open an inbox and point your provider at it. It is answered before you have finished reading this sentence.

Read the API docs