Skip to main content

September 21, 2026

Sparse webhooks need hydrate or a 503 retry

BamHub liveshow webhooks sync into Sanity for a public listing. Sparse payloads left shows unpublished until I hydrated from the API or returned 503 for retry.

Sander Korf3 min read
sanitynextjsbambuser

Merchandising runs a liveshow product on a fashion storefront: BamHub is the broadcast console, Sanity holds the public listing, and a Next.js route handler sits in the middle. When ops publishes a show in BamHub, they expect it on the /liveshow overview within minutes. Instead, some shows looked live in BamHub and never appeared in the Sanity-powered list. Slack stayed quiet. The webhook returned 200. The gap stayed until someone opened Sanity Studio and toggled publish by hand.

BamHub webhooks are sparse notifications, not full snapshots. A show-updated payload might omit published, ship a stale flag, or skip fields the storefront GROQ query requires. Writing the JSON body straight into a Sanity patch looked fine in logs: 200, document touched, vendor acknowledged. Sanity stayed draft or invisible to the public query anyway. One optimistic write was enough. Retries from the vendor would not fix data we had already persisted wrong.

I had treated the webhook body like the source of truth because the vendor said "updated." That is the trap. The event only means "go look again," not "here is the row."

Hydrate before you patch Sanity

When a server-side Bambuser API key is configured, the handler calls GET /shows/{id} before any Sanity write. That response carries authoritative publish state, title, schedule, and identifiers the sparse webhook skipped. The patch applies the hydrated shape, not the raw webhook alone.

If hydration fails with a server error, rate limit, or network fault, the handler does not write Sanity at all. It responds with 503 Service Unavailable so Bambuser's delivery pipeline retries later. Transient outages never freeze a half-truth document in the CMS. Client errors (4xx after a bad id) still map to 500 so we do not retry forever on garbage input.

When no API key is present (local dev or a stripped environment), the code falls back to payload-only sync. That path is explicit and logged so nobody confuses it with production behavior.

const hydrated = await fetchShowFromBambuser(showId);
if (!hydrated.ok && hydrated.retryable) {
	return new Response('Upstream unavailable', { status: 503 });
}
await sanity.patch(showId).set(buildSyncFields(hydrated.data ?? body)).commit();

Why 503 beats an optimistic 200

Acknowledging a webhook with 200 while writing incomplete fields tells the vendor to stop trying. The show stays wrong in Sanity until manual cleanup. Returning 503 on hydrate failure keeps the event in the retry queue. Idempotent patches mean a later attempt can land the full document without duplicating rows.

I log hydrate failures with the show id and upstream status. Ops can correlate a burst of 503s with BamHub incidents instead of debugging missing rows days later.

Sanity patches stay idempotent: the same show id upserts the same document shape. A retry after 503 does not fork history. That mattered when I chose 503 over writing partial fields just to silence the webhook.

For local handlers I still validate signature and map BamHub ids onto Sanity _id values before any network call. Hydration adds latency on every event. I pay it to avoid silent CMS drift and the "why is this show missing?" thread that always lands on a Friday.

Sparse vendor webhooks are triggers to re-read the source of truth. Refuse to commit CMS state when that read fails.


Happy coding! Sander