webhookjar
API reference, version 1

The Webhook Jar API

Make inboxes from your own code, wait for a webhook to land, and check what came in. Built for test runs: one call makes an inbox, one call waits for the request.

Base URLhttps://webhookjar.com/v1
llms.txt for AI tools
Get a free API keySign in with Google and a key is made for you
Get a key
1,000 calls a day on Free25,000 a day and 5 keys on Solo, $9 a month
See plans
Examples in

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.

Header
Authorization: Bearer wj_live_7f3a9c21d04b…
Without a key401
{
  "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.

Free$0
1 key, 1,000 calls a day, 30 a minute, 7 days of history
Solo$9 a month
5 keys, 25,000 calls a day, 300 a minute, 90 days of history, replay and forwarding

A waiting call to /next counts once, however long it waits.

Headers on every answer
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.

400bad_requestA parameter or the body is missing, not JSON, or the wrong type. The message names it.
401no_keyNo key, or a key that was rolled or revoked.
403planSolo only, like replay and forwarding, or your plan's inboxes are all in use.
404not_foundNo such inbox or request on this account, or one that expired or was closed.
405methodThat path does not take this method. The Allow header lists the ones it does.
429slow_downOver a limit. Wait for the Retry-After seconds.
500serverSomething failed on our side. It is logged. Try again in a moment.

When to retry

  • Retry only 429 and 500. On 429, wait the Retry-After seconds; refused calls do not count against your day.
  • On 500, wait 1, 2, then 4 seconds, and stop after 3 tries.
  • Never retry 400 to 405 unchanged: the same call gets the same answer.
  • 204 from Wait for a request is not an error: nothing arrived in time.
  • POST /v1/inboxes is 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.

Each endpoint's own errors, in Markdown for AI tools and code review: /docs/errors.md.
Example404
{
  "error": {
    "code": "not_found",
    "message": "No inbox ib_zz01 on this account"
  }
}
Handling it, in Node
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

Right the first timeReal paths, fields and ids, so generated code runs without guessing.
Errors handled wellEvery code, when it happens and whether to retry, so agents back off on 429 and stop on 401.
Always currentWritten from the live limits and settings, never a copy that drifts from the API.
Small to readAbout 17 KB, a few thousand tokens. It fits any context window with room to spare.
One link to shareHand a teammate or a tool one URL instead of a tour of the docs.
Open to any toolPlain text over https, no key, no login, open to any origin.

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.
Fetch it
curl -s https://webhookjar.com/llms.txt
curl -s https://webhookjar.com/docs/errors.md
The first lines
# 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-Key works too.

The tools

create_inboxchanges
Open an inbox
list_inboxesreads
List inboxes
get_inboxreads
Get an inbox
wait_for_requestreads
Wait for the next webhook
list_requestsreads
List captured requests
send_samplechanges
Send a real sample webhook
get_requestreads
Get a request
set_inbox_answerchanges
Set what the inbox answers
set_forwardingchanges
Forward to your server (Solo)
replay_requestchanges
Replay a request (Solo)
delete_inboxdeletes
Delete an inbox
get_usagereads
Plan and usage

What an agent does with it

  • "Open an inbox, point my Stripe test webhook at it, and tell me what invoice.paid carries."
  • "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."
A refused call (no key, a Solo-only tool, a limit) comes back as a tool result that says why, so the agent can tell you instead of guessing.
Claude Code
claude mcp add --transport http webhookjar https://webhookjar.com/mcp \
  --header "Authorization: Bearer wj_live_…"
Cursor and other clients
{
  "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.

Fetch it
curl -s https://webhookjar.com/skill.md
curl -s https://webhookjar.com/.well-known/agent-skills/index.json
Install for Claude Code
mkdir -p ~/.claude/skills/webhookjar
curl -s https://webhookjar.com/skill.md \
  -o ~/.claude/skills/webhookjar/SKILL.md

Make an inbox

POST/v1/inboxes

Makes 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

namestring, optional
Up to 60 characters. Shown in My inboxes.
Request
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()
Answer201
{
  "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

GET/v1/inboxes

Every inbox on your account that is still open, busiest first.

Parameters

keptboolean, optional
true for kept inboxes only, false for the ones that expire.
Request
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()
Answer200
{
  "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

GET/v1/inboxes/{id}

One inbox, with its counts, what it answers and its forwarding.

Parameters

idpath, required
The inbox id, ib_…
Request
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()
Answer200
{
  "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

PATCH/v1/inboxes/{id}

Rename it, set what it answers, and on Solo turn forwarding on or off. Send only what changes.

Parameters

namestring, optional
A new name
answerobject, optional
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)
forwardobject, Solo
url and on. The address must be public http or https.
Request
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()
Answer200
{
  "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

DELETE/v1/inboxes/{id}

Deletes the inbox and every request in it. Its post URL stops answering at once.

Parameters

idpath, required
The inbox id
Request
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)
Answer204
No body

Wait for the next request

GET/v1/inboxes/{id}/next

Holds 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

waitinteger, optional
Seconds to wait, 0 to 25. Default 25.
afterstring, optional
A request id. Answers the first one after it straight away if there is one, so nothing is missed between calls. Without it, only a request that lands after your call counts.
In a test: make an inbox, give its post_url to the provider, trigger the event, then call this. Pass the last id you saw as after to keep going.
Request
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()
Answer200
{
  "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

GET/v1/inboxes/{id}/requests

An inbox's requests, newest first. Bodies are left out here; get one request for its body.

Parameters

limitinteger, optional
1 to 100. Default 50.
beforestring, optional
A request id, for the next page. Pass the next you were given.
providerstring, optional
Only one sender: stripe, github, shopify and so on.
Request
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()
Answer200
{
  "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

GET/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

idpath, required
The request id, rq_…
Request
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()
Answer200
{
  "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

POST/v1/inboxes/{id}/samples

Delivers 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

providerstring, required
github, stripe, shopify, slack, twilio, sendgrid or standard (OpenAI)
eventstring, required
For example push, invoice.paid, orders/create or response.completed
No provider account needed: a test can send itself a GitHub push or a Stripe invoice and assert on what your handler does with it.
Request
curl -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()
Answer201
{
  "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

DELETE/v1/requests/{id}

Deletes one request.

Parameters

idpath, required
The request id
Request
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)
Answer204
No body

Replay a request

POST/v1/requests/{id}/replaySolo

Sends 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

idpath, required
The request id
Request
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()
Answer200
{
  "delivery": {
    "status": "ok",
    "http": 200,
    "ms": 142,
    "url": "https://api.northwind.io/hooks",
    "error": null
  }
}

Usage

GET/v1/usage

Your plan, what you have used today and what is left. Also a quick way to check a key works.

No parameters

Request
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()
Answer200
{
  "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"
}