What a Commerce Webhook Must Guarantee: Notes on UCP's Event Webhooks RFC
Yesterday, in Shop MCP Without Webhooks, I designed a poller because there was nothing to subscribe to. The obvious follow-up question is: when the push channel does arrive, what should it promise? By coincidence, an RFC opened on October 13, 2026 in the Universal Commerce Protocol repository tries to answer exactly that for UCP: PR #938, “Capability Event Webhooks”.
This post reads that RFC from the receiving end: the OMS, ERP or WMS integration that has to turn deliveries into correct state. As in the earlier pieces (After the Buy Button, When the OMS Speaks MCP), I separate what the RFC says from my inference. The PR is open, has no review discussion yet as I write, and may change.
What the RFC actually proposes
From the PR description and diff:
- Today, only
dev.ucp.shopping.orderpushes server-to-server updates. A platform declareswebhook_urlin its Orderconfig, and the business POSTs signed order snapshots there. The generic mechanics (Standard Webhooks headers, RFC 9421 signatures,2xxacknowledgement, retries) live inside the Order spec. Every other capability is poll-only. - Proposed: any capability can declare events named
{capability}:{name}, e.g.dev.ucp.common.location.lookup:updated. The colon cannot appear in a reverse-domain name, so parsing is unambiguous. - Subscription lives in capability config.
capability.jsongains an optionalconfig.webhook: platforms seturlandevents; businesses list theeventsthey send. The delivered set is the intersection. A business withoutwebhooksends nothing, “so platforms know to poll”. The business echoes the effective events in its responses so the platform knows whether it is subscribed. - The profile is the lease. No subscription API and no expiry to renew. A subscription ends when the platform removes
webhook, answers410 Gone, deliveries exhaust the retry schedule, or the platform stops being recognized. - Two payload shapes. Single-recipient transactional capabilities like Order MAY send a full entity snapshot. Shared or broadcast capabilities use a thin
event.jsonenvelope:ucp,type,occurred_at,datawith identifiers and achanged_sectionshint. - “Events signal; reads are authoritative.” A business SHOULD send only after the change is committed; a platform treats a thin event as a reason to read, never as state.
- Delivery contract (Appendix A): HTTP
POSTtowebhook.urleven when requests use MCP; RFC 9421 signature with a key from the sender profile’skeys[]; covered components MUST includewebhook-id,webhook-timestampanducp-agent;Webhook-Timestampis the delivery attempt time and receivers reject deliveries outside a short window “such as five minutes”;Webhook-Idis unique per event and identical across retries; receivers return2xxquickly and process asynchronously; businesses MUST retry5xx,429and network errors with exponential backoff. - Order converges.
webhook_urlis deprecated in favor ofconfig.webhook.url; Order keeps its full-order snapshot. - First new use: Location
updatedandremovedevents, so a listing surface learns about an emergency closure without polling every location.
It explicitly defers to two open issues for the hard parts: #215 (normative delivery semantics for order webhooks) and #865 (no MCP-transport equivalent). “Whatever #215 and #865 decide for the Order capability then applies to every capability event.”
At-least-once, so dedupe is yours
The RFC says retries are mandatory and Webhook-Id is stable across them. That is at-least-once delivery by construction, and the RFC does not claim anything stronger. Good: anything stronger would be a lie.
My inference on what the receiver needs:
- A durable
processed_webhook_idstable with a unique constraint, written in the same transaction as the state change it causes, or before enqueuing. Checking an in-memory cache is not dedupe. - Retention longer than the sender’s retry window. The RFC does not say how long retries last, so you cannot size this from the spec. Pick days, not minutes.
- Dedupe by
Webhook-Idis only the first layer. A business can legitimately emit two different events for the same change (a retry of its own internal job, say). For snapshots, the second layer is “is this state newer than what I have?”, which brings us to ordering.
The RFC adds a nice detail here: webhook-id becomes a signed component, so an attacker cannot replay a valid body under a fresh id to slip past your dedupe.
Ordering: the RFC sidesteps it, mostly on purpose
The RFC does not promise ordered delivery and does not define a sequence or version field in the envelope. What it gives you is occurred_at (event time) separated from Webhook-Timestamp (attempt time), and the argument that for thin events “processing one twice, late, or out of order is harmless” because you always read current state.
That argument is correct for thin events. It is weaker for Order, which keeps full snapshots. If order snapshot v3 is retried and lands after v4, a naive consumer overwrites newer state with older state. With exponential backoff, that inversion is not an edge case; it is the normal outcome of any single failed attempt.
What I would do (inference):
- Never apply a snapshot blindly. Compare against a monotonic marker. If the Order entity carries an
updated_ator revision, use it;occurred_atis a fallback, but wall-clock timestamps from a distributed sender can tie or skew. - If no reliable marker exists, treat even an Order snapshot as a thin event: note the order id, then read the current order.
- Keep transitions in your OMS forward-only (the same state-machine rule as in the polling post). A late
processingaftershippedis ignored, not applied.
A per-entity sequence or version field in the envelope would make this unnecessary guesswork. It is the single addition I would most like to see.
Snapshots versus deltas versus thin events
The RFC’s split is sensible and worth stating plainly:
| Shape | RFC use | Consumer cost | Failure behavior |
|---|---|---|---|
| Full snapshot | Order (one recipient) | No read-back needed | Out-of-order snapshot can regress state |
| Thin notification | Location, directory (broadcast) | One read per event (coalescible) | Duplicates and reordering are harmless |
| Delta / patch | Not proposed | Smallest payload | A missed delta corrupts state silently |
Deltas are absent, and I think that is the right call. For an ERP, a missed delta is the worst failure: nothing errors, the numbers are just wrong. Snapshots and thin events both converge.
For broadcast events the RFC adds a load note: receivers SHOULD coalesce repeated events for the same id before reading. In practice that means a short debounce keyed by entity id in front of your read worker (inference on the mechanism).
Ack fast, process async
The RFC is direct: platforms MUST respond quickly with 2xx and process read-backs asynchronously. It does not define “quickly” or a sender timeout.
Receiver pattern (inference, but standard):
- Verify the signature and timestamp window.
- Insert the
Webhook-Idand raw body into a durable inbox table. On unique-key conflict, return2xx(it is a duplicate you already have). - Return
2xx. - A worker drains the inbox, does the read-back or snapshot comparison, and writes to OMS/ERP.
Return non-2xx only when you failed to persist. Do not return 500 because the ERP downstream is slow; that turns your outage into the sender’s retry load. And be careful with 410 Gone: in this RFC it terminates the subscription. A generic framework 410 from a misrouted path would quietly unsubscribe you.
Retry storms and the end of a subscription
The RFC requires exponential backoff and ends the subscription when deliveries exhaust the retry schedule. It does not specify the schedule, jitter, maximum duration, or whether the business resubscribes once you recover.
That last point matters operationally. Inference from the lease model: since the platform profile is the lease and the business “rechecks whenever its cached copy goes stale”, a subscription killed by failures may come back on the next profile refresh. Or it may not. The text does not say, and your on-call runbook depends on it. Until clarified, assume a long outage can silently end delivery and that your poller must notice.
For the sender side, I would want jitter required alongside backoff, and 429 with Retry-After honored. Otherwise a receiver that comes back after an outage gets every queued retry from every business at once.
Signatures and key rotation
What the RFC says: sign with RFC 9421 using a key in the sender profile’s keys[]; cover webhook-id, webhook-timestamp, ucp-agent plus the components Order already signs (method, authority, path, content-digest, content-type in the example). The signer MUST be the party the event is about.
Consumer checklist (inference):
- Resolve
keyidagainst the sender’s published profile, cache with the profile’s cache headers, and on unknownkeyidrefetch once before rejecting. That one refetch is what makes key rotation painless. - Verify
Content-Digestagainst the raw bytes before parsing JSON. - Enforce the timestamp window against the signed
Webhook-Timestamp, with modest clock-skew tolerance. - Verify the signer owns the entity: an event about order X must come from the business that owns order X. The RFC states this rule for Location; apply it everywhere.
Rotation overlap (how long an old key stays in keys[]) is defined, if anywhere, in the signatures spec, not this RFC. I did not verify it.
One sharp edge the RFC itself raises as open question 1: today the Order docs describe Webhook-Timestamp as event occurrence time; the RFC redefines it as attempt time. A receiver enforcing a five-minute window on the old meaning would reject every legitimate retry after five minutes. If you consume Order webhooks today, check which meaning your verifier assumes.
Replay, backfill, and the poller that never goes away
The RFC defines no replay or backfill endpoint and no event log cursor. For thin events that is fine by design: reading current state is the backfill. For Order, it means that if you missed events, you recover by reading orders, not by replaying events.
So the reconciliation poller from the previous post stays. Webhooks become the fast path; polling becomes the correctness path:
- Webhooks drive latency.
- A periodic sweep (orders updated since last watermark, minus a safety overlap) catches anything a dropped subscription, exhausted retry, or deployment gap missed.
- Alert when the sweep finds changes the webhooks did not deliver. That ratio is your real delivery SLO.
The RFC’s own signal helps: because the business echoes the effective config.webhook.events in responses, a consumer can detect that it is no longer subscribed by checking that echo on ordinary reads (inference on usage).
What the RFC covers, and what I’d still want
Covered well: one delivery contract instead of one per capability; signed id and timestamp; stable dedupe id; explicit async-ack rule; a clear stance that reads are authoritative; SSRF rules for webhook.url; and no new subscription API.
Gaps from the consumer seat, none of which the RFC claims to solve (several are delegated to #215):
- A per-entity sequence or version in the envelope or a guarantee in Order snapshots.
- A concrete retry schedule, maximum retry duration, jitter, and
Retry-Afterhandling. - A sender timeout, so “respond quickly” is testable.
- Behavior after a failure-terminated subscription: resubscribe on next profile fetch, or not.
- A way to trigger a test delivery and to list recent deliveries for debugging.
- MCP-only platforms: the RFC still delivers over HTTP POST “even when requests use MCP” and points to #865.
Related open proposals touch the same ground from the order side: #943 (order cancellation and reconciliation) and #944 (revision-bound post-order decisions). I have only read their titles for this post; whether they would emit events under this scheme is not stated in #938.
Checklist
Receiver (OMS / ERP / WMS integration):
- Verify RFC 9421 signature,
Content-Digest, and timestamp window on raw bytes. - Refetch the sender profile once on unknown
keyid. - Persist
Webhook-Id+ body to a durable inbox before returning2xx. - Return
2xxon duplicates; never return410unless you mean to unsubscribe. - Process asynchronously; coalesce thin events per entity id.
- Apply Order snapshots only if newer; forward-only state transitions.
- Keep a reconciliation sweep and alert on what webhooks missed.
- Watch the echoed
config.webhook.eventsto detect a dropped subscription. - Check whether your verifier treats
Webhook-Timestampas occurrence or attempt time.
Sender (business side):
- Emit only after commit.
- Keep
Webhook-Idstable across retries; backoff with jitter; honor429. - Prefer thin events wherever more than one party could receive them.
What I could not settle
- Whether the Order snapshot carries a field reliable enough to order snapshots by. The RFC does not add one.
- The retry schedule, its maximum duration, and the sender timeout. Deferred to #215, which is still open.
- Whether a subscription ended by delivery failures is restored on the next profile refresh.
- Key rotation overlap rules; they would live in the signatures spec, which I did not re-read for this post.
- How MCP-only platforms receive events. The RFC keeps HTTP POST and points to #865.
- The final shape of all of this. The PR was opened two days ago with no review comments yet; expect it to move.