Overview
Every call needs an API key, sent as a bearer token. Answers are JSON, times are UTC in ISO 8601, and ids carry a prefix so you can tell them apart: ib_ for an inbox, rq_ for a request.
Authentication
Send your key in the Authorization header as Bearer wj_live_…. Keys are made on the API keys tab of your account and shown once, when they are made. A missing, rolled or revoked key gets 401.
Keep keys out of front-end code. Anyone with a key can read every inbox on your account.
Authorization: Bearer wj_live_7f3a9c21d04b…
{
"error": {
"code": "no_key",
"message": "Send your API key as Authorization: Bearer wj_live_..."
}
}Limits
Calls are counted per account, across all its keys. The day starts again at 00:00 UTC. Over a limit, calls get 429 with a Retry-After header in seconds; refused calls do not use up the day.
A waiting call to /next counts once, however long it waits.
X-RateLimit-Limit: 300 X-RateLimit-Remaining: 287 X-Calls-Today: 1602 X-Calls-Day-Limit: 25000
Error handling
Every answer that is not 2xx carries an error object: a code to branch on, which never changes meaning, and a message that names the field or value at fault. New fields may be added to answers later, so ignore what you do not know.
bad_requestA parameter or the body is missing, not JSON, or the wrong type. The message names it.no_keyNo key, or a key that was rolled or revoked.planSolo only, like replay and forwarding, or your plan's inboxes are all in use.not_foundNo such inbox or request on this account, or one that expired or was closed.methodThat path does not take this method. The Allow header lists the ones it does.slow_downOver a limit. Wait for the Retry-After seconds.serverSomething failed on our side. It is logged. Try again in a moment.When to retry
- Retry only
429and500. On429, wait theRetry-Afterseconds; refused calls do not count against your day. - On
500, wait 1, 2, then 4 seconds, and stop after 3 tries. - Never retry
400to405unchanged: the same call gets the same answer. 204from Wait for a request is not an error: nothing arrived in time.POST /v1/inboxesis not idempotent: a retry after a timeout can make a second inbox.
Catch URLs
Senders posting to an inbox get answers of their own: 404 no_such_inbox, 410 when it expired, was closed or is full, 429 over 120 a minute or past the daily allowance, and 503 busy with Retry-After: 5 when nothing was saved. Every major provider retries those by itself.
{
"error": {
"code": "not_found",
"message": "No inbox ib_zz01 on this account"
}
}const r = await fetch(url, opts);
if (r.ok) return r.status === 204 ? null : r.json();
const { error } = await r.json();
if (r.status === 429) {
await sleep(r.headers.get("Retry-After") * 1000);
return retry();
}
if (error.code === "server" && tries < 3) {
await sleep(2 ** tries * 1000);
return retry();
}
throw new Error(`${error.code}: ${error.message}`);Docs for AI tools
The whole API is also one Markdown file, /llms.txt, written for AI assistants and coding tools. Paste the link into Claude, ChatGPT, Cursor or Copilot, or let an agent fetch it, and it has every endpoint, parameter, limit and error in one read.
Why we publish it
AI tools now write much of the code that sends and receives webhooks, and they are only as right as what they read. A docs page built for people is HTML, menus, tabs and scripts. An assistant that fetches it gets the navigation as noise, misses the example hidden behind a language tab, and fills the gaps from memory. That is where invented endpoints, wrong field names and retry loops on a 401 come from.
llms.txt is an open convention (llmstxt.org): one Markdown file at the root of a site that says what the service is and how to use it. Ours is written from the same settings the API runs on, so every limit and error code in it is the one the API enforces today.
What it gives you
How to use it
- In a chat: "Read https://webhookjar.com/llms.txt, then write a Jest test that waits for a Stripe webhook."
- In an editor: add the URL as a docs source in Cursor, or keep a copy in your repo for Copilot and Claude Code to read.
- In an agent: fetch it once at the start of a task. It answers without a key.
- Errors only: /docs/errors.md is the error-handling part on its own.
curl -s https://webhookjar.com/llms.txt curl -s https://webhookjar.com/docs/errors.md
# Webhook Jar
> Webhook Jar (https://webhookjar.com) is a private webhook
inbox for developers. Open an inbox, point any service that
sends webhooks (Stripe, GitHub, Shopify, Slack, Twilio or
anything that makes an HTTP request) at its post URL, and every
request lands live with its headers, raw body, sender, event
name and signature check. The REST API at
https://webhookjar.com/v1 makes inboxes from code, waits for the
next webhook, and reads, deletes, forwards and replays requests,
which makes webhook flows testable in CI.
This file is the complete API reference in Markdown, written for
AI assistants and coding tools. It is generated from the same
…MCP server for AI agents
Claude, Cursor and any other MCP client can drive Webhook Jar directly: open an inbox, wait for a webhook to land, read it and replay it, all as tools. The server is https://webhookjar.com/mcp (Streamable HTTP). It uses your API key, so it has your plan's limits and sees only your inboxes.
Connect it
- Claude Code: run the command on the right, with your key from API keys.
- Cursor, Windsurf and others: add the JSON on the right to the client's MCP settings.
- The key: sent as
Authorization: Bearer wj_live_….X-API-Keyworks too.
The tools
What an agent does with it
- "Open an inbox, point my Stripe test webhook at it, and tell me what
invoice.paidcarries." - "Wait for the GitHub push, check the signature matched, then replay it to my server."
- "Make my inbox answer 500 for a while, and show me how Shopify retries."
claude mcp add --transport http webhookjar https://webhookjar.com/mcp \ --header "Authorization: Bearer wj_live_…"
{
"mcpServers": {
"webhookjar": {
"url": "https://webhookjar.com/mcp",
"headers": { "Authorization": "Bearer wj_live_..." }
}
}
}Use with Claude and agents
The agent skill is one Markdown file that teaches an AI agent how to use Webhook Jar: when to reach for it, the calls and tools, the limits, the errors and the usual test flow. Agents that look for skills find it at /.well-known/agent-skills/index.json.
Install it
- Download: skill.md.
- Claude Code: save it as
~/.claude/skills/webhookjar/SKILL.md. - Claude apps: upload it as a skill in Claude's settings.
- Other agents: give them the link; it is plain Markdown, open to any origin.
Pair it with the MCP server so the agent can act, and llms.txt when it writes code against the API.
curl -s https://webhookjar.com/skill.md curl -s https://webhookjar.com/.well-known/agent-skills/index.json
mkdir -p ~/.claude/skills/webhookjar
curl -s https://webhookjar.com/skill.md \
-o ~/.claude/skills/webhookjar/SKILL.mdMake an inbox
/v1/inboxesMakes an inbox and gives you its post URL. It is kept for as long as your account is. Your plan holds 3 inboxes on Free and 25 on Solo; at the limit the call answers 403 plan and nothing is made. Delete one to make room.
Parameters
curl -X POST "https://webhookjar.com/v1/inboxes" \ -H "Authorization: Bearer $WJ_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Stripe test inbox"}'
const url = "https://webhookjar.com/v1/inboxes"; const r = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify({name: "Stripe test inbox"}) }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes" r = requests.post(url, headers={"Authorization": f"Bearer {KEY}"}, json={"name": "Stripe test inbox"}) data = r.json()
{
"id": "ib_8k2m",
"name": "Stripe test inbox",
"post_url": "https://webhookjar.com/in/p3jd9xq7wlm2",
"view_url": "https://webhookjar.com/inbox/hq81czt0v4",
"kept": true,
"expires_at": null,
"requests": 1284,
"last_request_at": "2026-09-29T14:32:08Z",
"answer": { "status": 200, "body": "{\"received\":true}", "delay": 0 },
"forward": { "url": "https://api.northwind.io/hooks", "on": true },
"created_at": "2026-09-12T08:14:03Z"
}List inboxes
/v1/inboxesEvery inbox on your account that is still open, busiest first.
Parameters
true for kept inboxes only, false for the ones that expire.curl "https://webhookjar.com/v1/inboxes" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/inboxes"; const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes" r = requests.get(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"inboxes": [
{
"id": "ib_8k2m",
"name": "Stripe test inbox",
"kept": true,
"requests": 1284
},
{
"id": "ib_4tq0",
"name": "GitHub app",
"kept": true,
"requests": 212
}
]
}Get an inbox
/v1/inboxes/{id}One inbox, with its counts, what it answers and its forwarding.
Parameters
ib_…curl "https://webhookjar.com/v1/inboxes/ib_8k2m" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/inboxes/ib_8k2m"; const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes/ib_8k2m" r = requests.get(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"id": "ib_8k2m",
"name": "Stripe test inbox",
"post_url": "https://webhookjar.com/in/p3jd9xq7wlm2",
"view_url": "https://webhookjar.com/inbox/hq81czt0v4",
"kept": true,
"expires_at": null,
"requests": 1284,
"last_request_at": "2026-09-29T14:32:08Z",
"answer": { "status": 200, "body": "{\"received\":true}", "delay": 0 },
"forward": { "url": "https://api.northwind.io/hooks", "on": true },
"created_at": "2026-09-12T08:14:03Z"
}Change an inbox
/v1/inboxes/{id}Rename it, set what it answers, and on Solo turn forwarding on or off. Send only what changes.
Parameters
status (200, 201, 202, 204, 400, 401, 403, 404, 409, 410, 422, 429, 500, 502, 503), body as a string, delay (0, 2, 10, 30 seconds) and challenges (answer Slack, Meta and Twitch checks for you)url and on. The address must be public http or https.curl -X PATCH "https://webhookjar.com/v1/inboxes/ib_8k2m" \ -H "Authorization: Bearer $WJ_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Stripe live", "forward": {"on": true}}'
const url = "https://webhookjar.com/v1/inboxes/ib_8k2m"; const r = await fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify({name: "Stripe live", forward: {on: true}}) }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes/ib_8k2m" r = requests.patch(url, headers={"Authorization": f"Bearer {KEY}"}, json={"name": "Stripe live", "forward": {"on": True}}) data = r.json()
{
"id": "ib_8k2m",
"name": "Stripe test inbox",
"post_url": "https://webhookjar.com/in/p3jd9xq7wlm2",
"view_url": "https://webhookjar.com/inbox/hq81czt0v4",
"kept": true,
"expires_at": null,
"requests": 1284,
"last_request_at": "2026-09-29T14:32:08Z",
"answer": { "status": 200, "body": "{\"received\":true}", "delay": 0 },
"forward": { "url": "https://api.northwind.io/hooks", "on": true },
"created_at": "2026-09-12T08:14:03Z"
}Delete an inbox
/v1/inboxes/{id}Deletes the inbox and every request in it. Its post URL stops answering at once.
Parameters
curl -X DELETE "https://webhookjar.com/v1/inboxes/ib_8k2m" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/inboxes/ib_8k2m"; const r = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${key}` } }); console.log(r.status);
url = "https://webhookjar.com/v1/inboxes/ib_8k2m" r = requests.delete(url, headers={"Authorization": f"Bearer {KEY}"}) print(r.status_code)
No bodyWait for the next request
/v1/inboxes/{id}/nextHolds the call open until a request lands, then answers with it. If nothing comes in time, answers 204 with no body, so you can call again or fail your test.
Parameters
post_url to the provider, trigger the event, then call this. Pass the last id you saw as after to keep going.curl "https://webhookjar.com/v1/inboxes/ib_8k2m/next?wait=25" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/inboxes/ib_8k2m/next?wait=25"; const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes/ib_8k2m/next?wait=25" r = requests.get(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"id": "rq_91fd2",
"inbox_id": "ib_8k2m",
"at": "2026-09-29T14:32:08.412Z",
"method": "POST",
"query": {},
"headers": {
"content-type": "application/json",
"stripe-signature": "t=1790690728,v1=5257a8..."
},
"body": "{\"id\":\"evt_1Q2w\",\"type\":\"invoice.paid\"}",
"body_encoding": "utf8",
"size": 1834,
"ip": "54.187.174.169",
"provider": "stripe",
"event": "invoice.paid",
"headline": "$49.00 paid by a customer",
"signature": "verified"
}List requests
/v1/inboxes/{id}/requestsAn inbox's requests, newest first. Bodies are left out here; get one request for its body.
Parameters
next you were given.stripe, github, shopify and so on.curl "https://webhookjar.com/v1/inboxes/ib_8k2m/requests" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/inboxes/ib_8k2m/requests"; const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes/ib_8k2m/requests" r = requests.get(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"requests": [
{
"id": "rq_91fd2",
"at": "2026-09-29T14:32:08.412Z",
"method": "POST",
"provider": "stripe",
"event": "invoice.paid",
"signature": "verified"
}
],
"next": "rq_91fa0"
}Get a request
/v1/requests/{id}Everything we caught, as it came: method, query, headers, the raw body (base64 when it is not text), who sent it and whether its signature matched the secret on the inbox: verified, failed, or null with no secret set.
Parameters
rq_…curl "https://webhookjar.com/v1/requests/rq_91fd2" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/requests/rq_91fd2"; const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/requests/rq_91fd2" r = requests.get(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"id": "rq_91fd2",
"inbox_id": "ib_8k2m",
"at": "2026-09-29T14:32:08.412Z",
"method": "POST",
"query": {},
"headers": {
"content-type": "application/json",
"stripe-signature": "t=1790690728,v1=5257a8..."
},
"body": "{\"id\":\"evt_1Q2w\",\"type\":\"invoice.paid\"}",
"body_encoding": "utf8",
"size": 1834,
"ip": "54.187.174.169",
"provider": "stripe",
"event": "invoice.paid",
"headline": "$49.00 paid by a customer",
"signature": "verified"
}Send a real sample
/v1/inboxes/{id}/samplesDelivers a real provider webhook into the inbox, with the provider's own headers, body and signature, stored as if it had been posted. Every check runs, and on Solo it is forwarded like any other request. It is signed with the inbox's own signing secret for that provider when one is set, so signature comes back verified. Marked "sample": true. GET /v1/samples lists every provider and event.
Parameters
github, stripe, shopify, slack, twilio, sendgrid or standard (OpenAI)push, invoice.paid, orders/create or response.completedcurl -X POST "https://webhookjar.com/v1/inboxes/ib_8k2m/samples" \ -H "Authorization: Bearer $WJ_KEY" \ -H "Content-Type: application/json" \ -d '{"provider": "stripe", "event": "invoice.paid"}'
const url = "https://webhookjar.com/v1/inboxes/ib_8k2m/samples"; const r = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body: JSON.stringify({provider: "stripe", event: "invoice.paid"}) }); const data = await r.json();
url = "https://webhookjar.com/v1/inboxes/ib_8k2m/samples" r = requests.post(url, headers={"Authorization": f"Bearer {KEY}"}, json={"provider": "stripe", "event": "invoice.paid"}) data = r.json()
{
"id": "rq_91fe0",
"inbox_id": "ib_8k2m",
"method": "POST",
"provider": "stripe",
"event": "invoice.paid",
"headline": "$49.00 paid by [email protected]",
"signature": "verified",
"sample": true,
"signed_with": "yours"
}Delete a request
/v1/requests/{id}Deletes one request.
Parameters
curl -X DELETE "https://webhookjar.com/v1/requests/rq_91fd2" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/requests/rq_91fd2"; const r = await fetch(url, { method: "DELETE", headers: { Authorization: `Bearer ${key}` } }); console.log(r.status);
url = "https://webhookjar.com/v1/requests/rq_91fd2" r = requests.delete(url, headers={"Authorization": f"Bearer {KEY}"}) print(r.status_code)
No bodyReplay a request
/v1/requests/{id}/replaySoloSends the request to the inbox's forward address again, exactly as it came, and answers with what your server said. It waits up to 10 seconds for your server.
Parameters
curl -X POST "https://webhookjar.com/v1/requests/rq_91fd2/replay" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/requests/rq_91fd2/replay"; const r = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/requests/rq_91fd2/replay" r = requests.post(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"delivery": {
"status": "ok",
"http": 200,
"ms": 142,
"url": "https://api.northwind.io/hooks",
"error": null
}
}Usage
/v1/usageYour plan, what you have used today and what is left. Also a quick way to check a key works.
No parameters
curl "https://webhookjar.com/v1/usage" \ -H "Authorization: Bearer $WJ_KEY"
const url = "https://webhookjar.com/v1/usage"; const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const data = await r.json();
url = "https://webhookjar.com/v1/usage" r = requests.get(url, headers={"Authorization": f"Bearer {KEY}"}) data = r.json()
{
"plan": "solo",
"calls": {
"today": 1602,
"refused_today": 7,
"day_limit": 25000,
"minute_limit": 300
},
"inboxes": { "kept": 6, "kept_limit": 25 },
"captured_today": { "requests": 4210, "limit": 50000 },
"history_days": 90,
"resets_at": "2026-09-30T00:00:00Z"
}