TypeScript and API design
Webhook Event Envelopes That Survive Schema Changes
A stable event envelope, explicit compatibility policy, and runtime parsing let TypeScript webhook consumers evolve without turning a provider change into a production incident.
A webhook is not a function call with unreliable timing. It is a message from a system that releases independently, retries independently, and may remember a different shape of the world than the application receiving it. A consumer can be well typed and thoroughly tested yet still make a wrong decision when a provider adds a field, introduces an event type, or changes the meaning of an optional value. The durable answer is not to pretend the payload will stay still. It is to build an event envelope that makes change visible, containable, and recoverable.
That distinction matters in products that coordinate payments, provisioning, delivery, or real-time state. The Custom Name Domain project coordinates registrar, billing, DNS, and mail-provider work through explicit stages. Mining Access uses payment-aware delivery transitions and Stripe Connect flows. JSleeve combines REST, sockets, timers, and jobs around durable session and game state. In each kind of system, an event is evidence to interpret carefully, not permission to mutate state blindly.
The payload will evolve even when the endpoint does not
Webhook integrations create an asymmetric compatibility problem. A provider can deploy an additive field today while the consumer deploys next week. It can send an event type the consumer has never seen, repeat an older event after a timeout, or deliver a later lifecycle state before a delayed earlier one. None of those conditions requires a faulty provider. They are normal consequences of distributed delivery.
The risky response is to model the entire incoming document as one optimistic TypeScript interface and immediately pass it into business logic. A declaration that asserts a request body is a known event only changes the compiler's opinion. It does not inspect the bytes received over the network. A cast can hide a missing identifier, an unexpected type, or a value whose format changed. Once that unverified object crosses the boundary, every downstream branch is encouraged to trust it.
Start instead by stating what the consumer promises to tolerate. Additive unknown fields are usually harmless. A missing required envelope identifier is not. An unknown event type may be recorded and acknowledged, but it should not trigger a guessed transition. A known type with an unsupported payload version should enter a deliberate compatibility path instead of failing later as an undefined-property exception.
This is a product decision as much as an API decision. A lost status update can delay a person waiting for a purchase, provisioning, or delivery. A duplicated update can issue a reward twice or move an order backward. Before choosing a parser or queue, write down which outcome is safer for each event family: acknowledge and defer, retry, quarantine for review, or reject the delivery.
Keep the envelope small, stable, and boring
An envelope is the compact metadata every received event needs before its domain payload becomes relevant. CloudEvents is useful as a design reference because it distinguishes common context attributes from extension and data fields. The exact standard is optional; the separation is the important part. A practical envelope normally has an immutable event id, a type, a source, an occurrence time, a schema or data version, and the unparsed data.
Use identifiers with different jobs. The provider event id answers whether a delivery has already been processed. A business object id answers which subscription, booking, or account is affected. A correlation id answers which request or workflow produced the sequence. Conflating them produces confusing idempotency rules. In particular, do not use a human-readable business label as a delivery key when one business object can legitimately generate many events.
Time deserves similar precision. Store the provider occurrence time as received data and the local receipt time as operational data. They answer different questions. A timestamp serialized in the RFC 3339 profile can be parsed consistently, but it does not establish arrival order. If an event contains a provider sequence number or a version for the business object, that is usually a better guard against stale state than comparing clocks.
Keep the version near the payload it versions. An event version that describes the envelope is not necessarily the same as a data version that describes a payment, listing, or subscription record. This small distinction pays off when transport metadata remains stable while the provider evolves a particular resource. It also makes migrations reviewable: readers can see exactly which boundary changed.
Separate receipt, verification, and interpretation
A dependable consumer has at least three stages. Receipt handles the HTTP boundary: constrain the body size, preserve raw bytes if the provider signature scheme requires them, verify the signature before trusting claims, and attach a local receipt id. Interpretation comes later: parse the stable envelope, choose a handler for a recognized type and data version, then perform a guarded domain transition. Persistence or a queue sits between these stages when work may be slow or when a retry must survive a process restart.
This separation prevents a common trap: doing all business work synchronously while the webhook connection is open. A slow vendor lookup, an email call, or a temporary database fault can cause the provider to retry. If the delivery key has not been durably recorded, a second request may perform the same side effect again. Instead, make acknowledgement policy explicit. Some systems should persist a verified receipt and return promptly, then process asynchronously. Others may need to reject a delivery so the provider retries. What matters is that the response follows a documented durability point, not incidental timing.
The raw document should have a narrow lifetime. Keep it only where the security, retention, and replay model permits it; redact or minimize sensitive data in logs. Operational records often need only the provider event id, type, source, receipt time, selected handler version, outcome, and a safe error code. That is enough to answer why a message was deferred without turning application logs into a second copy of private customer data.
Use TypeScript types after runtime evidence
TypeScript makes post-parse code clearer, not network input safe by itself. Treat unknown as the honest type at the trust boundary. Check that the value is an object, verify required primitive fields, validate the discriminant, and only then narrow to the handler input type. The TypeScript handbook describes how type guards and control flow narrow a union; that is most valuable after runtime checks establish the facts those guards claim.
For a compact implementation, write a parser that returns either a normalized envelope or a structured rejection. The normalized form can convert provider naming conventions into internal names, parse timestamps, and retain an opaque data field. A second parser, selected by type and data version, validates only the version it understands. This gives the application a safe place to accept an unknown future event without accidentally treating it as a known one.
JSON Schema can provide a shared contract when multiple teams or languages need the same structural rules. Its object keywords also make an important policy visible: whether extra properties are allowed, ignored, or forbidden. Strictly forbidding every unknown property may sound safe, but it turns additive provider changes into outages. Permitting unknown fields everywhere has the opposite failure mode: a renamed required field can silently become absent. Choose separately for the envelope and for each versioned payload, then test that choice.
Avoid validation that erases useful distinctions. Null, absent, an empty string, and an unknown enum member can have different business meanings. Preserve them in the parsed result until the handler decides what they mean. A generic default can convert a compatibility question into an incorrect transition.
Define compatibility before a provider forces it
Schema evolution is easier when changes are classified before implementation. Additive optional data is normally forward-compatible when consumers ignore fields they do not use. Adding a new enum value is only safe if every consumer has an explicit unknown branch. Renaming a field, changing units, changing a field from optional to required, or reusing an existing event type for a different business meaning is breaking even when the JSON remains valid.
For each type, maintain a small compatibility table in code or documentation: supported versions, deprecated versions, minimum data needed to make a transition, and the fallback for an unknown version. A consumer can route an early booking update to one adapter, a newer update to another adapter, and a later unsupported update to a durable compatibility queue. That queue is not a trash bin. It should produce an alert with enough metadata to guide a deliberate handler release.
Adapters are usually safer than prematurely converting every historical document into one giant universal type. Each adapter turns a known provider version into an internal command with a stable meaning, such as recording a payment capture or completing a provisioning step. Domain code sees the command, not the provider changing vocabulary. The effect is especially helpful when a workflow spans vendors: registrar provisioning, subscription status, email setup, marketplace payment, and shipping may all have different event conventions even though the local product needs coherent stages.
Do not version only the documentation. The consumer needs its version policy in executable routing, fixtures, and alerts. When an event becomes unsupported, the outcome should be deterministic: no handler runs, no state changes, and an operator can see precisely which type, source, and version require attention.
Make retries and ordering part of the contract
At-least-once delivery is a useful default assumption. A provider can retry after the consumer completed work but before the acknowledgement was observed. The consumer therefore needs a durable uniqueness rule around the provider event id or another documented idempotency key. Store the key with the result of the attempted transition, not merely in process memory. A lock without a final record can still allow duplication after a crash.
Idempotency does not solve ordering. A repeated subscription update may be safe to ignore, while an earlier state arriving late may still be valid but stale. Use provider sequence numbers, resource revisions, or guarded database predicates where available. If there is no ordering signal, model the transition as a comparison of allowed states rather than assigning a received status wholesale. A transition from paid back to pending should need a specific explanation, not occur because a delayed message happened to arrive last.
Retries must distinguish retryable infrastructure errors from invalid or unsupported input. A transient database timeout may be retried with bounded backoff. A failed signature should be rejected and monitored, not endlessly retried. An unsupported version should be retained only if the retention policy allows it and should alert the owning team. Mixing all failures into one retry loop makes incidents noisier and hides the next useful action.
The same discipline applies to side effects. Record the intended local transition and any outbound action under the same idempotency decision where possible. If a worker crashes after updating state but before sending a notification, the recovery path should be explicit. If the provider endpoint itself is called back, give that request its own idempotency key. One event can produce several independently retryable operations.
Test the negative space of the contract
Happy-path fixtures prove only that the current sample is understood. Compatibility tests should also send an unknown top-level field, an unknown event type, a known type with a future data version, a missing event id, a duplicate id, an invalid timestamp, a stale revision, and an out-of-order pair. Assert not just the HTTP status but the durable outcome: whether a receipt was recorded, whether a handler ran, whether the state changed, and whether an alertable classification was emitted.
Keep fixtures close to provider documentation, but own the expectations. A provider sandbox can be useful for signature and delivery behavior; it is not a substitute for repeatable local tests. A contract test should make an accidental broadening or narrowing of acceptance obvious in code review. When a new provider version is adopted, add its fixture before changing the handler and retain the old fixture until the deprecation window has ended.
Finally, rehearse recovery. Pick a quarantined event, release support for its version, and replay it through the same idempotency path used in production. If replay requires editing database rows by hand, the system has not actually defined a recovery mechanism. The goal is not to process every unfamiliar message automatically. It is to ensure that an unfamiliar message becomes a small, explainable operational task rather than a hidden correctness risk.
Treat visibility as part of the interface
A webhook boundary should be observable without exposing payload contents. Count receipts, verified events, duplicate suppressions, unsupported types, unsupported versions, successful transitions, and permanent failures. Break those counts down by source and type. Put a trace or correlation identifier into safe logs and admin diagnostics, then make it possible to trace from a provider event id to the local receipt and final outcome.
This gives engineers and operators a shared language during an incident. Instead of asking whether webhooks are broken, they can see that verification is succeeding, one new event type is being deferred, and existing payment events are still completing. That is the difference between an integration that merely accepts JSON and one that can evolve under real delivery conditions.
A stable envelope will not stop providers from changing. It makes those changes legible. By treating input as unknown, separating receipt from interpretation, versioning payload meaning explicitly, and proving behavior with negative contract tests, a TypeScript consumer can remain cautious without becoming brittle. That is a worthwhile boundary for any product whose state depends on systems it does not deploy.
Primary sources
- 1.TypeScript Handbook: Narrowing — Microsoft
- 2.CloudEvents Specification — Cloud Native Computing Foundation
- 3.JSON Schema: Object — JSON Schema
- 4.RFC 3339: Date and Time on the Internet: Timestamps — RFC Editor
Portfolio evidence
Custom Name Domain
Delivered the central commerce and branded-email workflows needed to move multi-vendor setup into a repeatable self-service product path.
View case studyMining Access
Delivered the core marketplace domains and integration paths needed for buyer, provider, and administrator workflows.
View case studyJSleeve
Established reusable real-time services for personalized shot analysis and reliable competitive game progression.
View case studyRelated writing
Boundary Parsing in TypeScript: Keep External APIs From Becoming Internal Types
Treat every request, webhook, and provider response as unknown until a small boundary parser turns it into an explicit domain value.
Designing Idempotent Third-Party Integrations That Recover Cleanly
A production-minded method for making provider calls, webhooks, retries, reconciliation, and operator recovery converge on one trustworthy result.

