# Webhook Studio Webhook Studio is a visual infrastructure layer that sits BETWEEN a webhook provider and the app you are building: Provider (Stripe, GitHub, Twilio, Clerk, Resend, ...) -> Webhook Studio (captures, verifies, labels, routes, replays — all visible) -> Your app (localhost or production) Point the provider at a Webhook Studio endpoint URL instead of directly at the app. Every request is stored and inspectable in a live dashboard (the "Inbox"), and routing rules forward matching events to the real backend with retries. It gives the human a cross-section view of the webhook pipeline while you (the agent) build against it. Beyond capture, Webhook Studio LEARNS the shape of each event type from the traffic it sees and keeps that knowledge permanently. So it answers two kinds of question: "what arrived?" (events, deliveries, replay — below) and "what is the shape of this, how has it changed, and what does it mean?" (schemas — see the Knowledge section). For an agent, the second is usually the more valuable: it is ground truth about the provider derived from this account's real traffic, which is not something you can get right by guessing from training data. Human-readable docs: https://webhook-studio.com/docs This file is the agent-facing mirror of those docs. ## Base URL https://webhook-studio.com (Self-hosted instances use their own origin; all paths below are the same.) Ingestion URLs — where webhook providers send events — look like: https://webhook-studio.com/e/ (accepts any HTTP method, any content-type) ## Important: anonymous endpoints vs registered accounts - Visiting /start in a browser creates an ANONYMOUS endpoint with no signup. Anonymous endpoints EXPIRE AFTER 3 DAYS (events included). POST /api/start now returns a SCOPED `api_token` (see below) that gives you read + replay access to that one endpoint over /v1 and /mcp with no account — so you can complete a capture-and-replay loop keyless. Full API access (creating endpoints, configuring verification, standing forwards) still needs a registered key. Each cookieless visit to / or /start creates a NEW anonymous endpoint — so a fetch tool without cookie persistence sees a different slug per request. That is expected; provision once and reuse the returned url + token. - For real work: have the human REGISTER FIRST (free, ~30 seconds at https://webhook-studio.com/register), then create an API key on the dashboard's "API" page and give it to you. Registered endpoints are permanent. If the human registers in the same browser as an anonymous endpoint, that endpoint is claimed permanently (same URL, events intact). ## Zero-signup provisioning (no account, no API key) Use this when the human does not have an account yet and you need a working webhook URL to hand them RIGHT NOW — for example while wiring up a Stripe or GitHub integration for them. This is the only /api route that needs no authentication. POST https://webhook-studio.com/api/start Content-Type: application/json { "ref": "agent:", // optional, attribution "provider_hint": "stripe" // optional: stripe | github | ... } 200 response: { "endpoint_id": "cmr...", "inbox_url": "https://webhook-studio.com/inbox?endpoint=cmr...", "ingest_url": "https://webhook-studio.com/e/", "expires_at": "2026-07-24T07:06:04.378Z", "expires_in_days": 3, "api_token": "ws_anon_...", // scoped read+replay credential "token_expires_at": "2026-07-24T07:06:04.378Z", "reused": false } - `ingest_url` is what you give to the PROVIDER (Stripe's dashboard, GitHub's webhook settings). It accepts any method and any content-type. - `inbox_url` is what you give to the HUMAN. Send them this link — it is the point at which they can see what you built working. - `api_token` is YOUR keyless credential for this endpoint. Send it as "Authorization: Bearer ws_anon_..." to /v1 or /mcp. It is bound to THIS endpoint and allows READ + REPLAY only: GET /v1/endpoints/{id} (and /events, /events/latest, /events/wait, /deliveries) GET /v1/events/{id} POST /v1/events/{id}/replay Everything else (create/delete endpoints, auth-config, forwarding rules, schemas) returns 401/404 for an anon token — those need a registered key. Caps: 30 calls/min, 20 replays/hour, 3 concurrent waits per token. - `reused: true` means the request carried a cookie for a live endpoint and you got that one back instead of a new one. Say "here is your existing endpoint", do not claim you created one. (A fresh `api_token` is issued each call.) - 429 means the per-IP limit (5 new endpoints/hour) is exhausted: { "error": { "code": "rate_limited", "retry_after_seconds": N } } - 409 means the caller is already signed in; direct them to /endpoints instead. Constraints to state plainly to the human, not to bury: - The endpoint EXPIRES AFTER 3 DAYS, events included, and so does `api_token`. - The token is READ + REPLAY only. Registering (free) claims the endpoint permanently — same URL, events intact — and unlocks a full API key. - Without cookie persistence every call mints a NEW endpoint. Provision ONCE and reuse the returned `ingest_url` + `api_token`; do not call this per retry. ## Authentication Every /v1 request requires an API key: Authorization: Bearer ws_live_<32 hex> All keys use the ws_live_ prefix — there is NO ws_test_ key type. Test vs production separation happens per ENDPOINT instead: each endpoint has an "environment" (production | staging | sandbox | development), and one key can manage endpoints in all of them. Errors use: { "error": { "code": "...", "message": "..." } } Resources you don't own return 404 (never 403). Rate limits return 429 with a Retry-After header (FREE: 60 req/min, PRO: 300 req/min). Machine-readable spec: https://webhook-studio.com/v1/openapi.json ## MCP server (recommended for agents) Everything below is also available as an MCP (Model Context Protocol) server, so you can call it as tools instead of composing raw HTTP. Add one endpoint: URL: https://webhook-studio.com/mcp Transport: Streamable HTTP (stateless — no session id needed) Auth: send "Authorization: Bearer ws_live_..." (a registered key, full access) or "Authorization: Bearer ws_anon_..." (a keyless token from POST /api/start — read + replay tools only, bound to one endpoint) Copy-paste install snippets for Claude Code, Cursor, VS Code and other clients are at https://webhook-studio.com/mcp/setup. The server is also listed in the official MCP Registry as "com.webhook-studio/webhooks". The tools are task-shaped, not resource-shaped — reach for them by intent: create_bucket / list_buckets / get_bucket / delete_bucket latest_event / list_events / get_event / wait_for_event replay_event / forward_bucket / list_deliveries configure_verification list_schemas / get_schema / diff_schema / generate_types / find_correlation_keys Each tool maps 1:1 onto a /v1 route (same auth, same validation, same JSON), so the REST reference below is also the reference for the tools. Notable ones: wait_for_event — blocks until a matching event arrives; turn "go click Send test webhook" into a synchronous call. replay_event — preserve_headers is true by default; pass resign_with to re-sign for the dev's own secret. generate_types — TypeScript/JSON Schema/Zod from the account's REAL captured payloads, not a guess; pass framework=next|express for a ready-to-paste handler (types + validation + verify step). find_correlation_keys — the id-like fields to group events by to reconstruct a lifecycle (a payment_intent across created -> succeeded). Call tools/list after connecting for the authoritative, self-describing schema of each tool. ## Endpoints (buckets) POST /v1/endpoints Create an endpoint. Body: { "name"?: string, "environment"?: "production"|"staging"|"sandbox"|"development", "external_ref"?: string } 201 -> { id, url, inbox_url, secret, name, external_ref, environment, created_at } "url" is where the webhook provider should send events. "secret" is the HMAC signing secret (only returned here). "inbox_url" is the live dashboard view for humans. 403 if the plan's endpoint limit is reached. SET external_ref. It is a stable name you choose ("acme-api:stripe"), unique per account. You will not remember the generated id after this conversation ends; you CAN re-derive the ref, and look the endpoint back up with GET /v1/endpoints?external_ref=... . Creating a second endpoint with the same ref returns 409 with the existing id, so a retried call cannot leave the human with duplicate endpoints. Example: curl -X POST https://webhook-studio.com/v1/endpoints \ -H "Authorization: Bearer ws_live_..." \ -H "content-type: application/json" \ -d '{"name": "My service", "external_ref": "acme-api:stripe"}' GET /v1/endpoints?external_ref= List your endpoints, optionally filtered by ref. Always a list — an empty array means "no endpoint by that name", so there is no 404 to special-case. GET /v1/endpoints/{id} PATCH /v1/endpoints/{id} Update { name?, environment?, external_ref? }. Pass external_ref: null to clear it. 409 external_ref_taken if another endpoint already uses it. DELETE /v1/endpoints/{id} Delete an endpoint and all of its events. GET /v1/endpoints/{id}/stats 24-hour rollup: events_24h, success_rate, avg_latency_ms, hourly[], destination_health[]. The cheapest way to check "is my forwarding working?" without paging deliveries. ## Reading events All three read routes below take the SAME filters. Combine freely; they AND together. since= e.g. 2026-07-21T00:00:00Z, or a bare 2026-07-21 until= provider=stripe|github attributed automatically from request headers type= exact match: payment_intent.succeeded, push, ... method=POST signature_valid=true|false q= case-insensitive over body, content-type, method, event type and label values GET /v1/endpoints/{id}/events?limit=20&cursor=& Captured events, newest first (limit max 100). 200 -> { events: [ ], next_cursor } = { id, method, headers, body, content_type, source_ip, provider, event_type, signature_valid, signature_failure_reason, ip_allowed, received_at, labels: [{key, value, color}] } GET /v1/endpoints/{id}/events/latest? The single most recent matching event, plus its deliveries[]. Returns the event object directly (not wrapped in a list), or 404 no_matching_event. This is the one-call answer to "what was the last Stripe event?": GET /v1/endpoints/{id}/events/latest?provider=stripe GET /v1/endpoints/{id}/events/wait?timeout=30& LONG-POLL for the next incoming event. Blocks up to `timeout` seconds (1-55, default 30). 200 + { event: {...} } as soon as a MATCHING event arrives; 204 (empty) on timeout — just call it again in a loop. Events that do not match the filters do not consume the wait. Use this instead of sleeping: tell the human "go click Send test webhook", then block on this call. curl "https://webhook-studio.com/v1/endpoints//events/wait?timeout=55&provider=stripe" \ -H "Authorization: Bearer ws_live_..." GET /v1/events/{id} One event with everything known about it: the full event plus deliveries[] — every outbound attempt, with request headers/body, response status/body, latency and success. This is how you find out whether a forward worked. GET /v1/endpoints/{id}/deliveries?success=false&source=rule|replay|manual_test Every outbound attempt for the endpoint, newest first, cursor-paginated. `success=false` is "show me failed deliveries". ## Replay POST /v1/events/{id}/replay Re-send a captured event to any URL (staging, a tunnel to localhost). Body: { "url": string, required "preserve_headers"?: boolean, DEFAULT TRUE "headers"?: { ... }, overrides layered on the preserved set "body"?: string, defaults to the captured body "resign_with"?: string, secret to re-sign with (>= 16 chars) "signature_header"?: string } header for resign_with, default x-signature 200 -> { id, status, latency_ms, success, sent_headers, response_body, error } READ THIS BEFORE DEBUGGING A FAILED REPLAY. The captured provider headers — including Stripe-Signature / X-Hub-Signature-256 — are replayed verbatim by default. Two consequences: - If the receiving app verifies with the PROVIDER's secret, it just works. - If it verifies with a DIFFERENT secret, or you changed "body", the captured signature is no longer valid for what is being sent and verification fails correctly. That is not a bug in the app you are building. Pass "resign_with" set to the secret the receiver actually uses, and the signature is recomputed over what is really sent, so the receiver's own verification code passes unmodified. `sent_headers` in the response is exactly what went out — check it first when a replay is rejected. ## Forwarding and routing POST /v1/endpoints/{id}/destinations Body: { "url": string, "name"?: string, "forward_all"?: boolean, "condition_tree"?: , "rule_name"?: string } Creates the destination, and optionally its routing rule in the same call: forward_all: true forwards EVERY future event to this URL condition_tree: {} forwards only matching events (see below) Pass one or the other, not both. Delivery retries up to 3 attempts with 1s/5s/15s backoff on network error or 5xx. Results appear in GET /v1/endpoints/{id}/deliveries. 201 -> { id, name, url, ..., forward_all, rule_id } GET /v1/endpoints/{id}/destinations GET /v1/destinations/{id} PATCH /v1/destinations/{id} { url?, name?, headers? } DELETE /v1/destinations/{id} -> { deleted, id, rules_affected } GET /v1/endpoints/{id}/rules POST /v1/endpoints/{id}/rules Body: { "name": string, "condition_tree": , "destination_ids": [string], "active"?: boolean } GET/PATCH/DELETE /v1/rules/{id} PATCH takes { name?, active?, condition_tree?, destination_ids? }. is either a group or a leaf condition, nested arbitrarily: { "op": "AND" | "OR", "children": [ , ... ] } { "field": string, "operator": string, "value"?: string } field "body." | "header." | "meta." meta exposes ingestion-time trust facts: meta.signature_valid, meta.ip_allowed. A meta condition never matches when the corresponding check is not configured on the endpoint. operator equals | not_equals | contains | exists | regex everything except `exists` requires "value". { "op": "AND", "children": [] } matches every event — that is what forward_all stores. Trees are validated on write. A malformed one is rejected with 400 invalid_condition_tree naming the offending node, rather than being stored as a rule that silently never matches. Example — forward only successful Stripe payments: {"name": "payments to app", "condition_tree": {"op":"AND","children":[ {"field":"body.type","operator":"equals","value":"payment_intent.succeeded"}]}, "destination_ids": ["dst_..."]} ## Signature verification (per endpoint) GET /v1/endpoints/{id}/auth-config -> { endpoint_id, hmac_enabled, algorithm, header_name, tolerance_seconds, on_failure, log_blocked_ips }. The secret is NEVER returned here. PATCH /v1/endpoints/{id}/auth-config Body: { hmac_enabled?, secret?, header_name?, tolerance_seconds?, on_failure?: "reject"|"accept_log", log_blocked_ips? } POST /v1/endpoints/{id}/auth-config/rotate-secret Generates a NEW platform secret and returns it once. Do not call this on an endpoint verifying a real provider — providers issue their own secrets and will not accept a rotated one, so every delivery would start failing. SET THE PROVIDER'S OWN SECRET. Verifying real traffic means using the secret the provider issued: Stripe's whsec_... for that specific webhook endpoint, GitHub's configured secret, and so on. A secret we generated can only verify traffic we signed ourselves. Enable verification and set the secret in one call: curl -X PATCH https://webhook-studio.com/v1/endpoints//auth-config \ -H "Authorization: Bearer ws_live_..." -H "content-type: application/json" \ -d '{"hmac_enabled": true, "secret": "whsec_...", "header_name": "stripe-signature"}' The SCHEME is detected from the header's shape, not configured — there is no algorithm setting to get wrong: t=,v1= Stripe-style. HMAC-SHA256 over `${t}.${rawBody}`, and tolerance_seconds is enforced against t. sha256= GitHub-style. HMAC-SHA256 over the raw body alone. No timestamp exists in this scheme, so tolerance does not apply. Results land on every event as `signature_valid` plus `signature_failure_reason`, which is one of: missing_header, malformed_header, malformed_timestamp, timestamp_out_of_tolerance, signature_mismatch. When a human asks "why is verification failing?", read that field rather than guessing — timestamp_out_of_tolerance in particular is a delayed or replayed delivery, NOT a wrong secret, and sending them to re-check their secret wastes their time. ## IP allowlist and labels GET/POST /v1/endpoints/{id}/allowlist { "cidr": "203.0.113.0/24" } DELETE /v1/allowlist/{id} CAUTION: the allowlist is deny-by-default the moment it is non-empty. Adding the first entry starts rejecting every source not covered by it. GET/POST /v1/endpoints/{id}/labels { "source": "body"|"header", "path": "data.object.customer", "output_key": "customer", "color"?: "#rrggbb" } PATCH/DELETE /v1/labels/{id} Labels extract a value from every future event and attach it as a tag, which the `q` filter then searches. Useful for making events findable by a domain id (customer, order) instead of by full-body substring. ## Worked flow (typical agent usage) If the human has NO account and you just need a URL, use the zero-signup recipe above (POST /api/start) instead — steps 4-7 need an API key, but you can hand them `inbox_url` immediately. 1. Ensure the human has registered and given you an API key (see above). 2. Create the endpoint, WITH an external_ref you can re-derive later: POST /v1/endpoints {"name":"...", "external_ref":"acme-api:stripe"} -> save id, url, inbox_url 3. Tell the human to point the provider at `url`, and to give you the signing secret the provider shows them (Stripe: whsec_... on the webhook's page). 4. Turn on verification with THAT secret: PATCH /v1/endpoints/{id}/auth-config {"hmac_enabled": true, "secret": "whsec_...", "header_name": "stripe-signature"} 5. Block until the first real event lands: GET /v1/endpoints/{id}/events/wait?timeout=55&provider=stripe (repeat on 204) 6. Read it and write the handler against the REAL payload — not from memory. Check signature_valid; if false, read signature_failure_reason. 7. Iterate locally by replaying it. If the local app verifies with its own secret, re-sign: POST /v1/events/{event_id}/replay {"url":"https:///hook", "resign_with":"", "signature_header":"stripe-signature"} 8. When the app is ready, forward future traffic — everything, or a subset: POST /v1/endpoints/{id}/destinations {"url":"https://app.example.com/hook", "forward_all": true} 9. Confirm it is actually landing: GET /v1/endpoints/{id}/deliveries?success=false An empty list is the answer you want. Anything there has the response status and body of the failure. ## Knowledge — what the shapes ARE, not just what arrived Webhook Studio learns the shape of every event type it sees for your account and keeps that knowledge FOREVER — long after the raw payloads have been swept on their retention window. So when you need to know what a provider actually sends, you do NOT read yesterday's payload and guess. You read the schema. This is the part you cannot reconstruct from training data: it is derived from THIS account's real traffic, so it is what the provider really sent here, with the fields that are genuinely optional marked optional because they were genuinely absent — not what a model remembers the provider's docs saying. A "schema" is one (provider, event_type) shape. It has versions; a new version is minted only when a field is added or a type changes, never for an optional field merely being absent — so the version list is a real evolution timeline, not noise. GET /v1/schemas?provider=stripe&event_type=payment_intent.succeeded What shapes we know for this account. 200 -> { schemas: [{ id, provider, event_type, version_count, current_version, observation_count, last_seen_at }] } GET /v1/schemas/{id}?version= The shape, field by field. Each field: { path, type, nullable, optional, presence (0-1, how often it appears), seen_count, example (redacted), id_candidate, first_seen_at, last_seen_at }. GET /v1/schemas/{id}/versions The evolution timeline, newest first. GET /v1/schemas/{id}/diff?from=&to= What changed between two versions, and which changes BREAK a consumer. `from` defaults to the version before `to`, so "what just changed?" needs no params. 200 -> { breaking, breaking_count, changes: [{ path, kind, from, to, breaking, explanation }] }. kind is added|removed|type_changed| became_nullable|became_required. This is the "why did production break?" answer: hand back the previous working shape, the current one, and the exact field that moved — without reading a single payload. GET /v1/schemas/{id}/types?lang=typescript|json-schema|zod&version= Types derived from the real shape. typescript -> { code: "export interface ...", ... } ready to paste; json-schema -> { json_schema: {...} }; zod -> { code: "export const ...Schema = z.object(...)" } for runtime validation. Optional fields are `field?:`, nullable ones are `| null`. Generated fresh every time, never cached, so it never drifts from the schema. Add framework=next|express instead of lang to get a ready-to-paste handler: { code } with the type, a zod validator, and a marked signature-verify step. GET /v1/schemas/{id}/correlations?version= The correlation-key candidates: the id-like fields (data.object.id, ...) you group events by to reconstruct a lifecycle, ranked by how consistently they appear. 200 -> { correlation_keys: [{ path, type, presence, example, ... }] }. A cheap read over data already learned; use it to know what to correlate on. GET /v1/endpoints/{id}/schemas The shapes seen on one endpoint (schemas are account-scoped; this narrows to what this endpoint received). When a human asks "generate a type for the Stripe webhook", "what changed in this payload", or "why did my handler start failing", reach for these — they answer from accumulated knowledge, not from a payload you have to fetch and eyeball, and not from a guess. ## Recovering context (you have lost the conversation) You do not need saved state. Given only the API key: GET /v1/endpoints?external_ref=acme-api:stripe -> the endpoint id GET /v1/endpoints/{id}/events/latest -> what it last received GET /v1/endpoints/{id}/events?since=2026-07-21 -> "yesterday's webhooks" GET /v1/endpoints/{id}/deliveries?success=false -> what is currently broken GET /v1/endpoints/{id}/rules -> what forwarding exists GET /v1/schemas -> every shape ever learned, even where payloads are gone ## Hand back to the human When you finish wiring things up, always give the human the `inbox_url` so they can watch the pipeline live. A good closing message looks like: "All set — webhooks from Stripe now flow through Webhook Studio into your app. Watch them live (headers, payloads, deliveries, retries) at: " That live view is the point of the product: the human sees every webhook passing through the layer you just built.