Webhook Studio
/Docs

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.

Anonymous endpoints expire after 3 days. The no-signup endpoint you get from /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.

Provider
Stripe, GitHub, Twilio, …
Webhook Studio
capture · verify · label · route · replay
Your app
localhost or production

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>
About the ws_live_ prefix: all API keys are 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.

Request body
ParamTypeRequiredDescription
namestringNoDefaults to "API endpoint".
environmentstringNoOne of production, staging, sandbox, development. Defaults to development.
Sample request
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"}'
Sample response
# 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.

Sample request
curl "https://webhook-studio.com/v1/endpoints" \
  -H "Authorization: Bearer ws_live_..."
Sample response
# 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.

Path parameters
ParamTypeRequiredDescription
idstringYesThe endpoint ID.
Sample request
curl "https://webhook-studio.com/v1/endpoints/<id>" \
  -H "Authorization: Bearer ws_live_..."
Sample response
# 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.

Path parameters
ParamTypeRequiredDescription
idstringYesThe endpoint ID.
Sample request
curl -X DELETE "https://webhook-studio.com/v1/endpoints/<id>" \
  -H "Authorization: Bearer ws_live_..."
Sample response
# 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.

Path parameters
ParamTypeRequiredDescription
idstringYesThe endpoint ID.
Query parameters
ParamTypeRequiredDescription
sincestringNoOnly events at or after this time. ISO 8601, or a bare YYYY-MM-DD.
untilstringNoOnly events at or before this time.
providerstringNostripe or github — attributed automatically from the request headers at capture time.
typestringNoThe provider’s own event name, matched exactly: payment_intent.succeeded, push, …
methodstringNoHTTP method.
signature_validbooleanNoOnly verified, or only unverified, events.
qstringNoCase-insensitive substring over body, content type, method, event type and label values.
limitintegerNoPage size. Defaults to 20, max 100.
cursorstringNoAn event ID from a previous response’s next_cursor, to page backwards from.
Sample request
curl "https://webhook-studio.com/v1/endpoints/<id>/events?provider=stripe&since=2026-07-21" \
  -H "Authorization: Bearer ws_live_..."
Sample response
# 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.

Path parameters
ParamTypeRequiredDescription
idstringYesThe endpoint ID.
Query parameters
ParamTypeRequiredDescription
timeoutintegerNoSeconds to block for, 1–55. Defaults to 30. Returns 204 with an empty body if it elapses.
Sample request
while true; do
  curl -s "https://webhook-studio.com/v1/endpoints/<id>/events/wait?timeout=55" \
    -H "Authorization: Bearer ws_live_..." && break
done
Sample response
# 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 again

POST/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.

Path parameters
ParamTypeRequiredDescription
idstringYesThe endpoint to attach a forward_all rule to (ignored otherwise — the destination itself is account-wide).
Request body
ParamTypeRequiredDescription
urlstringYesWhere to forward events.
namestringNoDefaults to the URL’s hostname.
forward_allbooleanNoIf true, creates a "Forward all traffic" routing rule linking this destination to the endpoint. Defaults to false.
Sample request
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}'
Sample response
# 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).

Path parameters
ParamTypeRequiredDescription
idstringYesAny endpoint ID you own — the pool returned is account-wide, not endpoint-scoped.
Sample request
curl "https://webhook-studio.com/v1/endpoints/<id>/destinations" \
  -H "Authorization: Bearer ws_live_..."
Sample response
# 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.

Path parameters
ParamTypeRequiredDescription
idstringYesThe event ID (not the endpoint ID).
Request body
ParamTypeRequiredDescription
urlstringYesWhere to send the replay.
preserve_headersbooleanNoDefault true. Replays the captured headers (minus hop-by-hop ones) so signature verification on your side sees what the provider actually sent.
headersobjectNoHeader overrides layered on top of the preserved set.
bodystringNoReplacement body. Defaults to the captured one.
resign_withstringNoRe-sign the payload with this secret, so a receiver configured with a different secret — or a modified body — still passes its own verification.
signature_headerstringNoWhich header resign_with writes. Defaults to x-signature.
Sample 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"}'
Sample response
# 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

LimitAnonymousFreeProTeam
Ingestion (req/min per endpoint)601206001,500
Max body size512 KB1 MB5 MB10 MB
Stored events per endpoint2005,000100,000500,000
Event retention3 days7 days30 days90 days
Max endpoints132050
API requests/min603001,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.