Skip to main content

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 admin scope.
  • 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 eventCanonical event_typeEntityTimestampAttributesSkip rule
checkouts/createcheckouts.createcustomer ← /body/customer/id/body/created_atcurrency, subtotal_price, total_price, total_taxskipped when /body/customer is null
checkouts/updatecheckouts.updatecustomer ← /body/customer/id/body/updated_atcurrency, subtotal_price, total_price, total_taxskipped when /body/customer is null
orders/cancelledorders.cancelledcustomer ← /body/customer/id/body/cancelled_atcancel_reason, currency, financial_status, total_priceskipped when /body/customer is null
orders/createorders.createcustomer ← /body/customer/id/body/created_atcurrency, financial_status, subtotal_price, total_price, total_taxskipped when /body/customer is null
orders/fulfilledorders.fulfilledcustomer ← /body/customer/id/body/updated_atcurrency, fulfillment_status, total_priceskipped when /body/customer is null
orders/paidorders.paidcustomer ← /body/customer/id/body/updated_atcurrency, financial_status, subtotal_price, total_price, total_taxskipped when /body/customer is null
refunds/createrefunds.createcustomer ← /body/customer/id/body/created_atnote, order_idskipped 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 canonical event_type, re-point attributes, add skip_if rules, or set enabled: false to drop a topic). An override can also normalize vendor values with a value_map (exact {from, to} pairs per attribute; other values pass through unchanged) or declare a timestamp_format (rfc3339 for RFC 3339 strings only, or unix_s, unix_ms, unix_us for 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:preview with {"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 the admin scope).

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​

SymptomCauseFix
401 on deliverieswrong signing secretre-copy it from the Shopify Webhooks settings page and update the source config
404 on deliverieswrong src_id in the URL, or a disabled sourcere-check the receiver URL against the source id; re-enable the source
guest checkouts missingpayload has no customer objectexpected — the baseline skip_if skips them; override it if you attribute guests differently
topic not arrivingwebhook not registered for itregister the topic against the receiver URL
events parked as mapping_failedan override points at a missing/invalid fieldfix the override, then replay from the dead-letter view