Webhooks
Event storage, retries, dead-letter, and OCC lessons.
Deep-dive on the ingestion internals; the flow overview lives in Webhook Pipeline.
Storage split
webhookEvents carries processing state (status, retryCount, nextRetryAt, timing fields) and a payloadId; the raw JSON lives in webhookPayloads. The split exists because dashboard queries loading thousands of events with inline payloads blew Convex's 16 MB query limit. Legacy inline payload fields are optional and cleared after migration.
The dispatcher
dispatchShopifyWebhook (convex/webhooks/shopify.ts) is the single topic→mutation switch, used byte-for-byte by both the live path (processWebhookJob) and the retry path (processWebhookRetry). Historical drift between two parallel switches corrupted product data and left vouchers unmarked — do not fork it.
Status machine and cron
pending → processed | failed → dead_letter. The 5-minute cron (retry-failed-webhooks):
- Recovers stuck
pendingevents older than 10 minutes (their workpool enqueue was lost), bounded by acreatedAt < now − 10minwindow — the unbounded scan read the same range live inserts write, and every incoming webhook became an OCC conflict with the cron. - Retries failed events (≤ 5 attempts,
2^retryCountminute backoff); pool saturation reverts the patch and stops the batch. - Dead-letters exhausted events for manual review.
Cleanup cron (03:00 UTC) deletes processed/pending events past 30 days plus their payload docs, in bounded batches via by_status_created restricted to deletable statuses.
Gotchas
- Bad HMAC returns 401 (Shopify doesn't retry); internal failure must return 500 (a 2xx loses the event).
- Product/inventory events for unknown products are acknowledged and dropped;
product_listings/addis never filtered — it's how new products enter. - Adding a topic: update
REQUIRED_WEBHOOK_TOPICS, deploy, then register on the store.