Ground Truth

OpenAPI Cannot Describe Your Events: Seven Things an Event Contract Must State

Mostafa DhouibMostafa Dhouib·11 min read
The short answer

Request-response interface description formats cannot express an event-driven contract. There is no caller, there is no meaningful response, delivery is at-least-once, and ordering is not guaranteed. Teams document their events in a request-response format anyway, and the resulting document omits exactly the properties that cause incidents. Here are the seven things an event contract has to state, and how a unique index becomes the place the contract is actually enforced.

OpenAPI Cannot Describe Your Events: Seven Things an Event Contract Must State

The short answer. A request-response interface description format cannot express an event-driven contract, because the four things it assumes are all false for events. There is no caller who initiated the exchange. There is no response that carries the outcome, only an acknowledgement that bytes arrived. One logical event may be delivered more than once, because delivery is at-least-once. And ordering is not guaranteed, so a cancellation can arrive before the creation it cancels. A document in that format describes paths, payload shapes and auth headers very well, and those are the parts that rarely break. It has nowhere to record delivery guarantee, ordering guarantee, idempotency key, retry window, schema evolution rules or redelivery behaviour, which are the parts that cause the incidents.

A team hands over an interface document for their webhook integration. It is neat. Every event type has a path, a payload schema, an example body, a bearer token in the header and a 200 response.

I read it, and I still do not know whether a payment event can arrive twice, whether a cancellation can land before the subscription it cancels, what happens if my endpoint is down for an hour, or whether a redelivered event carries the original payload or a freshly rendered one.

Those four questions are the integration. The document answered none of them, and it was not being sloppy. The format has no field for any of them.

Four assumptions that do not hold

There is a caller. The whole grammar of a request-response document is organised around somebody deciding to call. Events invert that. The producer decides, the consumer did not ask, and the interesting question is what state change in the producer causes an emission. There is no field for a trigger condition, because in a request-response world the trigger is "a client wanted to".

The response carries the outcome. A status code in a synchronous API tells the caller whether the thing happened. For an event, the status code tells the producer that bytes were received. Whether the consumer applied the event, rejected it, queued it, or crashed halfway through is invisible to the producer and, crucially, is not what the code means. So the response section of the document describes the least interesting part of the exchange with the most precision.

One request is one delivery. Request-response formats have no vocabulary for duplication because duplication is a client's business. Event delivery is at-least-once in practice. A duplicate is normal operation, not an error, and there is nowhere in the document to say so. Which means consumers get built by people who read the document and reasonably assumed once.

Order comes from the sequence of calls. A client makes call A then call B, so A happened first. Nothing provides that for events. Two events about the same entity may arrive in either order, and after a retry they very often do. There is no ordering field.

The result is not a document that is a bit thin. It is a document whose strongest sections cover the lowest-risk surface, and which is silent on every property that produces a production incident. Payload schemas are the one thing these formats do describe well for events, and they are worth keeping for that. The payload is just not the contract.

What the format describes precisely
Paths and payload schemas
well
worth keeping for exactly this, because the payload shape is genuinely useful
Auth header
well
The 200 response
with the most precision
and it means bytes arrived, not that the consumer applied the event
What causes the incidents
Delivery guarantee
no field
at-least-once in practice, so a duplicate is normal operation rather than an error
Ordering guarantee
no field
a cancellation can arrive before the creation it cancels, and after a retry it very often does
Idempotency key and its stability
no field
a key minted per attempt makes two deliveries indistinguishable from two events
Retry window
no field
it sets the minimum retention of the consumer's deduplication store, and nobody compares the two numbers
Redelivery semantics
no field
a re-rendered payload carries newer data under an older identifier, which collapses every ordering argument above it
the format has nowhere to put the right column
Keep the payload schemas. Stop treating them as the contract.
FigureThe document is not a bit thin. Its strongest sections cover the lowest-risk surface, and it has no field at all for any property on the right, because a request-response format assumes a caller, a response that carries the outcome, one delivery per request and ordering from call sequence. All four are false for events.

The seven things the contract must state

Per event type, in writing, before anybody builds a consumer.

1. The trigger. What state change in the producer causes this event, stated as a condition on the producer's data rather than as prose. Also whether it fires on every write or only on a transition, because "subscription updated" firing on a no-op save is a different consumer design from one that fires only on a real change.

2. The delivery guarantee. At-least-once, at-most-once, or best effort. Pick one and write it. At-least-once is almost always the honest answer. If a producer claims exactly-once, they either mean exactly-once effect at the consumer, which is the consumer's achievement and not the producer's, or they are mistaken.

3. The ordering guarantee. None, per-key, or global. Per-key is the useful one: ordered within a single subscription identifier, unordered across them. If the answer is none, the consumer needs a monotonic version or sequence number per key so it can discard anything stale, and that number has to be in the payload, which makes ordering a schema requirement rather than a footnote.

4. The idempotency key. Which field is it, what is its uniqueness scope, and is it stable across delivery attempts. That last clause is the one that gets missed and it decides whether deduplication is possible at all. If the producer mints a fresh identifier per attempt, two deliveries of the same business event are indistinguishable from two business events, and no amount of consumer cleverness recovers it. The key must be chosen by the producer at the moment the event is created and must be byte-identical on every redelivery.

5. The retry window and schedule. How many attempts, across what total elapsed time, with what backoff, and what happens at exhaustion: dead letter queue, alert, or silent drop. The total elapsed time is not trivia. It sets the minimum retention of the consumer's deduplication store. If the producer retries for seventy-two hours and the consumer remembers event identifiers for one hour, the consumer will process a duplicate as new and be correct according to its own code.

6. The schema evolution rules. Which changes the producer considers additive and may make without notice, which are breaking, how a version is signalled, and the consumer's obligation to ignore unknown fields. Name the fields that are guaranteed present forever, because those are the only ones a consumer may treat as required. Everything else should be read defensively, and a consumer that validates strictly against the current schema will break the first time the producer adds a field.

7. Redelivery semantics. On a second delivery of the same event, what must the consumer do and what may it assume. The specific question to ask is whether the payload is immutable. Some producers store the event body and resend it verbatim. Others re-render the payload from current state at delivery time, which means a redelivery can carry newer data under an older event identifier. If that is the behaviour, the event is not a fact about a moment, it is a hint to go and look, and every piece of ordering reasoning built on top of it collapses.

Two more belong in the same document even though they are not strictly contract terms. What a non-2xx response causes, and in particular whether a 4xx stops retries while a 5xx continues them, because consumers routinely return 400 for a payload they will never accept and then wonder why the event vanished. And the authenticity mechanism: what bytes the signature covers, the clock tolerance, and the replay window.

Why this is concrete: a recurring payment ledger

Take an event that says a recurring payment succeeded, feeding a ledger.

If that event arrives twice and the consumer increments a balance, the payer is credited twice and the ledger is wrong in a way that gets noticed by the person least equipped to understand it. The idempotency key has to be the payment processor's own payment identifier, stable across retries, not a delivery identifier minted per attempt.

If a subscription amount change can arrive out of order, applying the older event last silently reverts the change. The payer sees the old amount, nobody gets an error, and the only trace is a ledger entry whose timestamp makes no sense. The fix is a monotonic version per subscription in the payload, with the consumer rejecting any version lower than the one it has already applied.

Then make the enforcement physical. The ledger is append-only, with a unique index on the producer's event identifier. A duplicate delivery becomes a unique constraint violation on insert, which the consumer catches and treats as success, because it means the event was already applied. That is the whole deduplication mechanism, it lives in the database rather than in application logic, and it holds under concurrency, restarts and parallel workers, which a check-then-insert does not.

The unique index is where the contract is actually enforced. Everything in the document above exists to tell you which column to put it on.

The producer mints the key at creation
its own payment identifier, scoped to the business fact and byte-identical on every redelivery
A second delivery of the same event arrives
normal operation under at-least-once, not an error
Insert into the append-only ledger
with a unique index on the producer's event identifier
The constraint violates
caught and treated as success, because it means the event was already applied
Balance credited once
and an out-of-order amount change is rejected by a monotonic version per subscription in the payload
This holds under concurrency, restarts and parallel workers. A check followed by an insert does not.
FigureThe contract becomes physical at one line of schema. A duplicate delivery turns into a unique constraint violation on insert, which the consumer catches and treats as success, because the violation proves the event was already applied. Everything in the written contract exists to tell you which column to put the index on.

Where to write it down

An event-oriented description format exists and is the right shape for this, and a plain document per event type with the seven headings works just as well. The format matters much less than the headings being answered, and the useful discipline is to make those seven items a review checklist that a new event type cannot pass without.

If you are keeping a request-response document because tooling depends on it, keep it for payload schemas and add a sibling document for guarantees. Just stop treating the first one as the contract, because a consumer built only from it will be correct on the shape of the data and wrong about everything that will wake somebody up.

The honest limit

A better document format verifies nothing. The guarantees still have to be tested, and the three tests nobody writes are the ones that matter: deliver the same event twice and assert the state is identical, deliver two events for one key in reverse order and assert the final state matches the newer one, and deliver a duplicate after the deduplication retention has expired and see what happens. That last one usually fails, and finding out in a test is the entire point.

The harder limitation is that most producers are third parties who have not written any of this down and will not do so because you asked. There you are reduced to determining the behaviour empirically: observe whether duplicates occur, whether payloads are identical on redelivery, what the retry schedule looks like from the receiving side. Record what you observed and the date you observed it, and treat it as an observation rather than a guarantee, because it is not one and it can change under you without notice.

Some of these properties are not knowable from outside at all. You cannot determine a producer's retry window by watching successful deliveries, and you cannot distinguish "ordering is guaranteed" from "ordering has not yet been violated". For those, design for the weaker assumption and move on.

FAQ

Can I use OpenAPI to document my webhooks? Use it for payload schemas, which it describes well, and not as the contract. A request-response format assumes a caller, a response that carries the outcome, one delivery per request and ordering from call sequence. All four are false for events, so the document has no field for delivery guarantee, ordering guarantee, idempotency key, retry window or redelivery behaviour.

What belongs in an event contract? Seven things per event type: the trigger condition in the producer's data, the delivery guarantee, the ordering guarantee, the idempotency key and whether it is stable across attempts, the retry window and what happens at exhaustion, the schema evolution rules, and what a redelivery means. Add what a non-2xx response causes and what the signature covers.

Why does the retry window matter to the consumer? Because it sets the minimum retention for the consumer's deduplication store. If a producer retries across three days and the consumer remembers event identifiers for an hour, a late duplicate is processed as a new event and the consumer is behaving correctly according to its own code. The two numbers have to be compared deliberately, and usually nobody has.

What makes a good idempotency key? A value the producer chooses when the event is created, scoped to the business fact rather than the delivery attempt, and byte-identical on every redelivery. A payment processor's payment identifier qualifies. A per-attempt delivery identifier does not, because it makes two deliveries of one event indistinguishable from two events, which no consumer logic can recover from.

How do I handle events that arrive out of order? Carry a monotonic version or sequence number per key in the payload and have the consumer reject anything lower than what it has already applied. Without such a field, out-of-order delivery silently reverts changes: applying an older update last leaves the old value in place with no error anywhere. Ordering is therefore a schema requirement, not a note.

Where should duplicate detection live? In the database, as a unique index on the producer's event identifier over an append-only table. A duplicate insert raises a constraint violation the consumer catches and treats as success, since it proves the event was already applied. This holds under concurrency, restarts and parallel workers, which an application-level check followed by an insert does not.

Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program