Webhook Studio docs
Webhook Studio is the visual layer between your webhook provider and your app. Webhooks flow through it on the way to your backend — so you get a live cross-section view of every request: headers, bodies, signatures, deliveries, and retries.
Quickstart
Receive your first webhook in under a minute. Get an endpoint URL:
# In a browser: visit https://webhook-studio.com/start (instant, no signup)
# Or with an API key (see Authentication below):
curl -X POST https://webhook-studio.com/v1/endpoints \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"name": "My service"}'Send anything to it — any HTTP method, any content type:
curl -X POST https://webhook-studio.com/e/<your-slug> \
-H "content-type: application/json" \
-d '{"type": "order.created", "amount": 4200}'
# -> {"received": true, "id": "evt_..."}The event appears instantly in your Inbox, pushed over a WebSocket. That URL is what you paste into Stripe, GitHub, Twilio, Clerk, Resend, or any other provider's webhook settings.
/start is a sandbox — it and all its events are deleted after 3 days. Before wiring it into a provider config you care about, register (free): if you register in the same browser, your endpoint, URL, and events are claimed permanently.How it fits together
Point your provider at Webhook Studio instead of directly at your app. Every request is captured and inspectable, then routing rules forward matching events to your real backend with retries. Nothing about your app changes — it still receives an HTTP POST — but now you can see under the hood of the pipe.
While you're still building, there's no "your app" yet — events just accumulate in the Inbox where you can inspect and replay them. When your handler is ready, add a destination with forward_all and traffic starts flowing through.
Authentication
Create an API key on the dashboard's API page (requires a free account). The plaintext key is shown exactly once and stored as a SHA-256 hash. Send it on every /v1 request:
Authorization: Bearer ws_live_<32 hex chars>ws_live_ — there is no ws_test_ key type. Test/production separation happens at the endpoint level instead: each endpoint has an environment (production, staging, sandbox, development), so one key can manage endpoints across all of them.Errors use a consistent envelope, and resources you don't own return 404 (never 403). Rate-limited requests return 429 with a Retry-After header.
{ "error": { "code": "invalid_api_key", "message": "This API key is invalid or has been revoked." } }API reference
Base URL: https://webhook-studio.com. All responses are snake_case JSON.
POST/v1/endpoints
Creates a new endpoint under your account, with its own ingestion URL and HMAC signing secret.
| Param | Type | Required | Description |
|---|---|---|---|
name | string | No | Defaults to "API endpoint". |
environment | string | No | One of production, staging, sandbox, development. Defaults to development. |
curl -X POST https://webhook-studio.com/v1/endpoints \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"name": "Checkout service", "environment": "development"}'# 201 Created
{
"id": "cmrn...",
"url": "https://webhook-studio.com/e/ab12cd34ef56",
"inbox_url": "https://webhook-studio.com/inbox?endpoint=cmrn...",
"secret": "whsec_...",
"name": "Checkout service",
"environment": "development",
"created_at": "2026-07-16T12:00:00.000Z"
}
# 403 Forbidden if you're at your plan's endpoint limit:
{ "error": { "code": "endpoint_limit_reached", "message": "Your plan allows up to 3 endpoints. Upgrade to create more." } }GET/v1/endpoints
Lists every endpoint owned by your account.
curl "https://webhook-studio.com/v1/endpoints" \
-H "Authorization: Bearer ws_live_..."# 200 OK
{ "endpoints": [ { "id": "cmrn...", "url": "...", "inbox_url": "...", "name": "...", "environment": "development", "created_at": "..." } ] }GET/v1/endpoints/{id}
Retrieves details for a single endpoint you own.
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The endpoint ID. |
curl "https://webhook-studio.com/v1/endpoints/<id>" \
-H "Authorization: Bearer ws_live_..."# 200 OK
{ "id": "cmrn...", "url": "https://webhook-studio.com/e/ab12cd34ef56", "inbox_url": "https://webhook-studio.com/inbox?endpoint=cmrn...", "name": "Checkout service", "environment": "development", "created_at": "2026-07-16T12:00:00.000Z" }
# 404 Not Found if the ID doesn't exist or isn't yours (never 403 — see Authentication above):
{ "error": { "code": "not_found", "message": "Endpoint not found." } }DELETE/v1/endpoints/{id}
Permanently deletes an endpoint and every event captured on it.
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The endpoint ID. |
curl -X DELETE "https://webhook-studio.com/v1/endpoints/<id>" \
-H "Authorization: Bearer ws_live_..."# 200 OK
{ "deleted": true, "id": "cmrn..." }GET/v1/endpoints/{id}/events
Lists events captured on this endpoint, newest first, with cursor-based pagination. The filters below also work on /events/latest and /events/wait.
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The endpoint ID. |
| Param | Type | Required | Description |
|---|---|---|---|
since | string | No | Only events at or after this time. ISO 8601, or a bare YYYY-MM-DD. |
until | string | No | Only events at or before this time. |
provider | string | No | stripe or github — attributed automatically from the request headers at capture time. |
type | string | No | The provider’s own event name, matched exactly: payment_intent.succeeded, push, … |
method | string | No | HTTP method. |
signature_valid | boolean | No | Only verified, or only unverified, events. |
q | string | No | Case-insensitive substring over body, content type, method, event type and label values. |
limit | integer | No | Page size. Defaults to 20, max 100. |
cursor | string | No | An event ID from a previous response’s next_cursor, to page backwards from. |
curl "https://webhook-studio.com/v1/endpoints/<id>/events?provider=stripe&since=2026-07-21" \
-H "Authorization: Bearer ws_live_..."# 200 OK
{
"events": [
{
"id": "evt...",
"method": "POST",
"headers": { "content-type": "application/json", "...": "..." },
"body": "{\"type\":\"order.created\"}",
"content_type": "application/json",
"source_ip": "203.0.113.9",
"provider": "stripe",
"event_type": "order.created",
"signature_valid": null,
"signature_failure_reason": null,
"ip_allowed": null,
"received_at": "2026-07-16T12:01:30.000Z",
"labels": [{ "key": "type", "value": "order.created", "color": "blue" }]
}
],
"next_cursor": "evt..."
}GET/v1/endpoints/{id}/events/wait
Long-polls for the next event on this endpoint, blocking until one arrives or the timeout elapses — the primitive for "trigger a webhook, then react to it" from a script or agent.
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The endpoint ID. |
| Param | Type | Required | Description |
|---|---|---|---|
timeout | integer | No | Seconds to block for, 1–55. Defaults to 30. Returns 204 with an empty body if it elapses. |
while true; do
curl -s "https://webhook-studio.com/v1/endpoints/<id>/events/wait?timeout=55" \
-H "Authorization: Bearer ws_live_..." && break
done# 200 OK — an event arrived before the timeout
{ "event": { "id": "evt...", "method": "POST", "headers": {...}, "body": "...", "content_type": "application/json", "source_ip": "203.0.113.9", "signature_valid": null, "received_at": "...", "labels": [] } }
# 204 No Content — timed out, nothing arrived; just call it againPOST/v1/endpoints/{id}/destinations
Adds a URL to your account's shared destination pool, optionally wiring up a routing rule that forwards every future event on this endpoint to it.
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The endpoint to attach a forward_all rule to (ignored otherwise — the destination itself is account-wide). |
| Param | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Where to forward events. |
name | string | No | Defaults to the URL’s hostname. |
forward_all | boolean | No | If true, creates a "Forward all traffic" routing rule linking this destination to the endpoint. Defaults to false. |
curl -X POST https://webhook-studio.com/v1/endpoints/<id>/destinations \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"url": "https://app.example.com/api/webhooks", "forward_all": true}'# 201 Created
{ "id": "dst...", "name": "app.example.com", "url": "https://app.example.com/api/webhooks", "created_at": "2026-07-16T12:00:00.000Z", "forward_all": true, "rule_id": "rul..." }GET/v1/endpoints/{id}/destinations
Lists your account's destination pool (shared across all your endpoints).
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Any endpoint ID you own — the pool returned is account-wide, not endpoint-scoped. |
curl "https://webhook-studio.com/v1/endpoints/<id>/destinations" \
-H "Authorization: Bearer ws_live_..."# 200 OK
{ "destinations": [ { "id": "dst...", "name": "app.example.com", "url": "https://app.example.com/api/webhooks", "created_at": "..." } ] }POST/v1/events/{id}/replay
Re-sends a captured event to any URL — a staging box or a tunnel to localhost. The original headers, including the provider's signature, are replayed verbatim by default.
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The event ID (not the endpoint ID). |
| Param | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Where to send the replay. |
preserve_headers | boolean | No | Default true. Replays the captured headers (minus hop-by-hop ones) so signature verification on your side sees what the provider actually sent. |
headers | object | No | Header overrides layered on top of the preserved set. |
body | string | No | Replacement body. Defaults to the captured one. |
resign_with | string | No | Re-sign the payload with this secret, so a receiver configured with a different secret — or a modified body — still passes its own verification. |
signature_header | string | No | Which header resign_with writes. Defaults to x-signature. |
curl -X POST https://webhook-studio.com/v1/events/<event_id>/replay \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"url": "http://localhost:3000/api/webhooks"}'# 200 OK
{ "id": "cly...", "status": 200, "latency_ms": 41, "success": true,
"sent_headers": { "stripe-signature": "t=...,v1=...", "content-type": "application/json" },
"response_body": "ok", "error": null }Recipe: test a Stripe webhook against localhost
The same flow works for Clerk, Resend, GitHub, Twilio — any provider with a webhook URL field.
# 1. Create an endpoint (or grab one from https://webhook-studio.com/start)
curl -X POST https://webhook-studio.com/v1/endpoints \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"name": "Stripe dev"}'
# -> save "id" and "url"
# 2. Paste "url" into Stripe -> Developers -> Webhooks -> Add endpoint
# (or: stripe trigger payment_intent.succeeded, with the CLI pointed at it)
# 3. Trigger a test event in Stripe, then catch it:
curl "https://webhook-studio.com/v1/endpoints/<id>/events/wait?timeout=55" \
-H "Authorization: Bearer ws_live_..."
# -> the full captured request: headers, raw body, source IP
# 4. Replay it at your local handler as many times as you need. The captured
# Stripe-Signature is replayed too, so constructEvent() sees a real request:
curl -X POST https://webhook-studio.com/v1/events/<event_id>/replay \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"url": "http://localhost:3000/api/webhooks/stripe"}'
# If your local handler is configured with a DIFFERENT signing secret than
# the one Stripe signed with, re-sign the replay for it instead:
# -d '{"url": "...", "resign_with": "whsec_local...",
# "signature_header": "stripe-signature"}'
# 5. Handler works? Forward everything from now on:
curl -X POST https://webhook-studio.com/v1/endpoints/<id>/destinations \
-H "Authorization: Bearer ws_live_..." \
-H "content-type: application/json" \
-d '{"url": "https://your-app.com/api/webhooks/stripe", "forward_all": true}'
# 6. Confirm it is landing. An empty list is the answer you want:
curl "https://webhook-studio.com/v1/endpoints/<id>/deliveries?success=false" \
-H "Authorization: Bearer ws_live_..."Your captured events stay in the Inbox, so you can keep replaying the same real payload against your handler while you iterate — no need to re-trigger the provider each time.
The full API — event filtering by time, provider and type, delivery history, routing rules, signature config — is specified at /v1/openapi.json, with agent-oriented usage notes at /llms.txt.
Receiving forwarded events
Forwarded and replayed events arrive at your app as a plain HTTP POST with the original raw body. Next.js route handler:
// app/api/webhooks/route.ts
export async function POST(req: Request) {
const payload = await req.json();
console.log('webhook:', payload.type);
// ... handle it
return Response.json({ ok: true });
}Express:
app.post('/api/webhooks', express.json(), (req, res) => {
console.log('webhook:', req.body.type);
res.json({ ok: true });
});Respond with a 2xx to acknowledge. 5xx responses and network errors are retried up to 3 times; every attempt (status, latency, response body) is visible in the dashboard.
Verifying signatures
Each endpoint can verify inbound HMAC signatures (enable on the Authentication page, or via the endpoint's secret). The scheme is Stripe-style: a x-signature header (name configurable) of the form t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, `${t}.${rawBody}`). To send a signed test request:
const crypto = require('crypto');
const secret = 'whsec_...'; // from POST /v1/endpoints
const body = JSON.stringify({ type: 'order.created', amount: 4200 });
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
await fetch('https://webhook-studio.com/e/<your-slug>', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-signature': `t=${t},v1=${v1}` },
body,
});Every captured event shows its verification result (signature_valid), and invalid signatures can be rejected with a 401 or accepted-and-flagged, per endpoint.
Verifying forwarded requests
When Webhook Studio forwards an event to one of your destinations, it can act as your edge auth layer: it verifies the original provider's signature once, then attaches its own signature so your backend trusts a single scheme instead of implementing Stripe/GitHub/Twilio verification itself. Enable Sign forwarded requestson a destination (Destinations tab). Each forward then carries:
x-webhookstudio-signature: t=<unix>,v1=<hex>
x-webhookstudio-verified: TRUE|FALSE # omitted if the source endpoint has no HMAC configured
x-webhookstudio-ip-allowed: TRUE|FALSE # omitted if the source endpoint has no IP allowlist
x-webhookstudio-event-id: evt_...The signature covers the trust flags and the body, so neither can be altered in transit without invalidating it. Recompute it and compare:
const crypto = require('crypto');
// Your destination's signing secret (Destinations tab -> Sign forwarded requests)
const secret = 'whsec_...';
function verify(req, rawBody) {
const [t, v1] = req.headers['x-webhookstudio-signature']
.split(',').map((p) => p.split('=')[1]);
const verified = req.headers['x-webhookstudio-verified'] ?? 'na'; // TRUE|FALSE|na
const ipAllowed = req.headers['x-webhookstudio-ip-allowed'] ?? 'na';
const signed = `${t}.${verified.toLowerCase()}.${ipAllowed.toLowerCase()}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}The v1 tag is a scheme version — future changes ship as v2without breaking verifiers pinned to v1. The secret is platform-generated by default, can be replaced with your own, and is rotatable.
Learned schemas
Webhook Studio learns the shape of every event type it sees for your account and keeps that knowledge permanently — long after the raw payloads have been swept on their retention window. A payload is bulky and short-lived; the shape it taught us is tiny and worth keeping forever.
So when you (or an agent on your behalf) need to know what a provider actually sends, you don’t re-read a payload and guess — you read the schema. It is derived from your account’s real traffic, so the optional fields are the ones that were genuinely sometimes absent, not what a model half-remembers from the provider’s docs.
# What shapes we've learned:
curl "https://webhook-studio.com/v1/schemas?provider=stripe" \
-H "Authorization: Bearer ws_live_..."
# A TypeScript interface from the real shape (optional fields marked optional):
curl "https://webhook-studio.com/v1/schemas/<schema_id>/types?lang=typescript" \
-H "Authorization: Bearer ws_live_..."
# What changed between two versions, and which changes break a consumer —
# the "why did production start failing?" answer, with no payload needed:
curl "https://webhook-studio.com/v1/schemas/<schema_id>/diff?from=1&to=current" \
-H "Authorization: Bearer ws_live_..."A new schema version is minted only when a field is added or a type changes — never just because an optional field happened to be absent — so the version list is a real evolution timeline rather than noise. Full route reference for schemas is in /v1/openapi.json and /llms.txt.
For AI agents
A plain-markdown mirror of this reference lives at https://webhook-studio.com/llms.txt — paste it into Claude, Cursor, or any agent and it has everything it needs: base URL, auth header, routes, and a worked end-to-end flow. Agents can drive the whole lifecycle through /v1 (create endpoint → wait for events → replay → forward), and can query learned schemas to generate types, diff versions, and explain breaking changes — all while the human watches everything live at the inbox_url returned when the endpoint is created.
Limits
| Limit | Anonymous | Free | Pro | Team |
|---|---|---|---|---|
| Ingestion (req/min per endpoint) | 60 | 120 | 600 | 1,500 |
| Max body size | 512 KB | 1 MB | 5 MB | 10 MB |
| Stored events per endpoint | 200 | 5,000 | 100,000 | 500,000 |
| Event retention | 3 days | 7 days | 30 days | 90 days |
| Max endpoints | 1 | 3 | 20 | 50 |
| API requests/min | — | 60 | 300 | 1,000 |
Anonymous endpoints (and everything captured on them) are deleted 3 days after creation. Registering from the same browser converts your anonymous endpoint into a permanent one — same URL, events intact. Pro is $9/mo (or $90/yr) and Team is $29/mo (or $290/yr) — see pricing for full details.