Types from your real traffic, not from a provider’s docs
Ask any model to type a Stripe webhook and you get something plausible and subtly wrong — the shape as it was in the training data, with every optional field marked required and the one field your account actually receives missing. Webhook Studio derives the shape from the deliveries that landed in your inbox. This page explains exactly how, including where it stops.
Start with a real payload, no account
Before any of the below matters you need one genuine delivery to look at. That part is keyless — one call returns a live ingestion URL and a token scoped to it:
curl -s -X POST https://webhook-studio.com/api/start \
-H 'Content-Type: application/json' \
-d '{"ref":"guide:schema-learning"}'Point a provider at the ingest_url it returns — that is the address events are delivered to — and watch them arrive at inbox_url. The response also carries an api_token (a ws_anon_ credential bound to this one endpoint) that works against /v1 and the MCP server with no account and no cookie. Nothing to install, no tunnel, no signup.
Why this is not a solved problem
The obvious approach — generate types from the provider’s published schema — fails in three ordinary ways. Providers version their APIs per account, so the payload your key receives is not the one in the current docs. Optional fields are documented as fields, with nothing saying which ones your integration will actually see. And a model generating types from memory cannot distinguish a field that is always present from one that appeared in an example once.
Every one of those is answerable from traffic, and only from traffic. That is the whole idea: the shape is not looked up, it is observed.
What a version means — the load-bearing decision
The naive rule is “a different set of fields means a new version.” It is wrong, and getting it wrong quietly destroys the feature. Providers send optional fields intermittently — Stripe’s cancellation_reason is there only sometimes — so under that rule every combination of present fields mints a version. Six months in you have hundreds of “versions” of one schema, real breaking changes are buried in the noise, and the diff is worthless.
A version therefore represents an expansion or a break, never presence variation:
| What arrived | Result |
|---|---|
| Known paths only, no type conflict | No new version. Counters advance. |
| A path never seen before | New version — the shape expanded |
| An existing path with a different concrete type | New version — the shape broke |
“Optional” falls out of this rather than being stored: a field is optional when it has been seen fewer times than the schema has been observed. Because it is derived, it cannot go stale — and that same ratio is exactly what code generation needs to choose between field: T and field?: T.
string and null is a nullable string, not a conflict — it is recorded in place with no new version. Only two different concrete types at one path are structural. A path we had only ever seen as null, later seen as a string, is likewise not a break: learning what an unknown actually is does not break anyone.How a payload becomes an observation
Each delivery is flattened to a set of paths with concrete types. Arrays are described by their element shape (items[].id), never by index, so a list of three items and a list of thirty are the same shape. The structural identity of that set is a hash over its sorted path:type pairs — sorted, because JSON key order is not semantic and a provider swapping serializers must not read as a schema change. Null-typed paths are excluded from the hash, since a field that happens to be null in this payload carries no type information.
Sample values are captured for the UI, but only after passing the same redaction predicates the payload viewer uses. Anything on a secret-looking path is dropped rather than masked — including a field hanging off a secret-named parent, like data.api_key.last4. An absent example costs a little polish; a leaked one would turn the schema store into a credential store.
Breaking is judged from the consumer’s side
“Breaking” is answered for the person whose handler just started throwing, not for the provider. A field the provider added is not a break for a handler that ignores unknown keys. A field it removed is, because something was reading it.
| Change | Breaking? | Why |
|---|---|---|
| Field removed | Yes | Code that read it now reads undefined |
| Type changed | Yes | Parsing or arithmetic on it will fail |
| Became nullable | Yes | A runtime hazard however rarely it shows |
| Field added | No | A handler ignoring unknown keys is unaffected |
Each change carries a signal: what fraction of the relevant version’s traffic actually exhibits it. A change appearing in under 5% of a stream with at least 20 observations behind it is flagged low-signal — it looks more like one malformed payload than an evolution. Thin evidence yields no flag at all rather than a confident “rare” that has not been earned.
What you get out
Types are generated on request and never stored. Persisted code drifts from its schema the moment the next event lands, and confidently-wrong types are worse than none.
# TypeScript for the current version
curl -H "Authorization: Bearer $WS_API_KEY" \
"https://webhook-studio.com/v1/schemas/$SCHEMA_ID/types?lang=typescript"
# ...or Zod, or JSON Schema
"...?lang=zod"
"...?lang=json-schema"
# ...or a ready-to-paste handler, with signature verification wired in
"...?framework=next" # also: express
# What changed between two versions, and what of it breaks you
curl -H "Authorization: Bearer $WS_API_KEY" \
"https://webhook-studio.com/v1/schemas/$SCHEMA_ID/diff?from=1&to=current"Versions are addressed by number or by the literal current, because that is what a person or an agent naturally writes. The same data is available through the MCP server, which is the point: an agent writing your handler can ask what the payload actually looks like instead of guessing from its training data.
What this does not do
It does not validate incoming traffic against a schema or reject anything — learning is observational and never blocks a delivery. It does not infer semantics: it knows amount is an integer, not that it is denominated in cents. It does not reconcile across accounts; your schema is built from your traffic only. And it needs traffic to be useful — the first delivery produces a version with every field marked required, and optionality only becomes meaningful once there is a spread of real events behind it.
Try it against something real
The honest order is: get a live URL with the one-liner above, point a real provider at it, and look at what actually arrives — the provider guides cover what each one sends and how it signs it. Schema learning starts accruing the moment that endpoint belongs to an account. How this compares to other webhook tools is a separate page, deliberately including where we are not competing.