Shopify webhook adapter
Turn Shopify store webhooks into canonical events with zero code:
orders/create, orders/paid, checkouts/create, refunds/create
become facts about your customer entities, straight from the store with
no ETL.
Prerequisites
- An API key with the
adminscope. - A Shopify store with permission to manage webhooks (or a custom app).
How authentication works (least privilege)
The adapter verifies Shopify's X-Shopify-Hmac-Sha256 header (base64
HMAC-SHA256 of the raw body, constant-time comparison) on every delivery
using the webhook signing secret — no Shopify Admin API token is stored.
Tessera can verify what the store sends, and nothing else. The secret is
envelope-encrypted with Cloud KMS the moment the source is created and is
never returned by any API.
Create the source
Create a kind=stream source with the adapter subtype:
{
"name": "shopify-store",
"kind": "stream",
"config": {
"subtype": "adapter",
"provider": "shopify",
"webhook_secret": "REPLACE_WITH_SHOPIFY_SIGNING_SECRET"
}
}
New sources default to the latest released mapping catalog; pass
"catalog_version": 1 to pin one explicitly.
The webhook_secret is write-only. Reads show {"set": true} in its place,
never the secret or its encrypted form, and an update that sends
{"set": true} back keeps the stored secret. An update that changes
provider must send the new provider's secret.
Register the webhooks in Shopify
The source id (src_…) in the create response is the last path segment of
your receiver URL:
https://ingest.staging.tessera.zone/v1/adapters/shopify/{src_id}
Register that URL per topic in Shopify (Settings → Notifications → Webhooks, or via the Admin API for a custom app). The signing secret is shown at the bottom of the same Webhooks settings page (for app-created webhooks it is the app's client secret). Subscribing to topics outside the coverage table below is harmless: those deliveries are acknowledged and dropped, never guessed.
Event coverage
Generated from the released shopify mapping catalog — the catalog file
is the single source of truth. Topics become dot-namespaced canonical
event types (orders/create → orders.create):
Catalog version v1 (new sources default to the latest released version; existing sources pin the version they were created with).
| Provider event | Canonical event_type | Entity | Timestamp | Attributes | Skip rule |
|---|---|---|---|---|---|
checkouts/create | checkouts.create | customer ← /body/customer/id | /body/created_at | currency, subtotal_price, total_price, total_tax | skipped when /body/customer is null |
checkouts/update | checkouts.update | customer ← /body/customer/id | /body/updated_at | currency, subtotal_price, total_price, total_tax | skipped when /body/customer is null |
orders/cancelled | orders.cancelled | customer ← /body/customer/id | /body/cancelled_at | cancel_reason, currency, financial_status, total_price | skipped when /body/customer is null |
orders/create | orders.create | customer ← /body/customer/id | /body/created_at | currency, financial_status, subtotal_price, total_price, total_tax | skipped when /body/customer is null |
orders/fulfilled | orders.fulfilled | customer ← /body/customer/id | /body/updated_at | currency, fulfillment_status, total_price | skipped when /body/customer is null |
orders/paid | orders.paid | customer ← /body/customer/id | /body/updated_at | currency, financial_status, subtotal_price, total_price, total_tax | skipped when /body/customer is null |
refunds/create | refunds.create | customer ← /body/customer/id | /body/created_at | note, order_id | skipped when /body/customer is null |
Pointers resolve against the delivery document {"headers": {…lowercased…}, "body": {…payload…}}; the event-type discriminator is /headers/x-shopify-topic.
Events are attributed to your customer entities via customer.id in the
payload; guest checkouts without a customer object are skipped by the
baseline skip_if rule. Order totals map exactly as Shopify sends them.
Retries and idempotency
The X-Shopify-Webhook-Id header becomes the canonical event_id, so
Shopify's at-least-once redeliveries are exact no-ops downstream. The
receiver answers 200 only after the mapped events are durably queued.
Customize the mapping
The mapping surface lives on the standard source-mapping routes:
GET /v1/sources/{src_id}/mapping— the resolved catalog + your overrides.PUT /v1/sources/{src_id}/mapping— per-topic overrides (rename the canonicalevent_type, re-point attributes, addskip_ifrules, or setenabled: falseto drop a topic). An override can also normalize vendor values with avalue_map(exact{from, to}pairs per attribute; other values pass through unchanged) or declare atimestamp_format(rfc3339for RFC 3339 strings only, orunix_s,unix_ms,unix_usfor epoch numbers). Leave it out to keep the default, which reads both RFC 3339 strings and epoch-second numbers. Overrides are validated field by field; the released catalog itself is read-only.POST /v1/sources/{src_id}/mapping:previewwith{"sample_payload": {"headers": {"x-shopify-topic": "orders/paid", …}, "body": {…}}}— Shopify routes on a header, so previews take the{headers, body}form; the response carries the exact events the live path would publish, with zero side effects (requires theadminscope).
Test and verify
Use a development store order (or Shopify's webhook test delivery), then
read the customer's timeline. HMAC failures are rejected at the door
(401), not parked; mapping failures on a mapped topic are parked in the
dead-letter view with reason=mapping_failed and can be replayed after
you fix the override.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 on deliveries | wrong signing secret | re-copy it from the Shopify Webhooks settings page and update the source config |
| 404 on deliveries | wrong src_id in the URL, or a disabled source | re-check the receiver URL against the source id; re-enable the source |
| guest checkouts missing | payload has no customer object | expected — the baseline skip_if skips them; override it if you attribute guests differently |
| topic not arriving | webhook not registered for it | register the topic against the receiver URL |
events parked as mapping_failed | an override points at a missing/invalid field | fix the override, then replay from the dead-letter view |