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.order pushes server-to-server updates. A platform declares webhook_url in its Order config, and the business POSTs signed order snapshots there. The generic mechanics (Standard Webhooks headers, RFC 9421 signatures, 2xx acknowledgement, 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.json gains an optional config.webhook: platforms set url and events; businesses list the events they send. The delivered set is the intersection. A business without webhook sends 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, answers 410 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.json envelope: ucp, type, occurred_at, data with identifiers and a changed_sections hint.
  • “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 POST to webhook.url even when requests use MCP; RFC 9421 signature with a key from the sender profile’s keys[]; covered components MUST include webhook-id, webhook-timestamp and ucp-agent; Webhook-Timestamp is the delivery attempt time and receivers reject deliveries outside a short window “such as five minutes”; Webhook-Id is unique per event and identical across retries; receivers return 2xx quickly and process asynchronously; businesses MUST retry 5xx, 429 and network errors with exponential backoff.
  • Order converges. webhook_url is deprecated in favor of config.webhook.url; Order keeps its full-order snapshot.
  • First new use: Location updated and removed events, 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_ids table 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-Id is 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_at or revision, use it; occurred_at is 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 processing after shipped is 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:

ShapeRFC useConsumer costFailure behavior
Full snapshotOrder (one recipient)No read-back neededOut-of-order snapshot can regress state
Thin notificationLocation, directory (broadcast)One read per event (coalescible)Duplicates and reordering are harmless
Delta / patchNot proposedSmallest payloadA 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):

  1. Verify the signature and timestamp window.
  2. Insert the Webhook-Id and raw body into a durable inbox table. On unique-key conflict, return 2xx (it is a duplicate you already have).
  3. Return 2xx.
  4. 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 keyid against the sender’s published profile, cache with the profile’s cache headers, and on unknown keyid refetch once before rejecting. That one refetch is what makes key rotation painless.
  • Verify Content-Digest against 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-After handling.
  • 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 returning 2xx.
  • Return 2xx on duplicates; never return 410 unless 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.events to detect a dropped subscription.
  • Check whether your verifier treats Webhook-Timestamp as occurrence or attempt time.

Sender (business side):

  • Emit only after commit.
  • Keep Webhook-Id stable across retries; backoff with jitter; honor 429.
  • 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.