Shopify Webhooks
Category: Finance & Commerce
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
Receive Shopify order webhooks, verify their signature, and upsert them into Postgres — with a reporting endpoint for recent orders and paid revenue.
Shopify's signature is base64, not hex
Every major webhook provider signs with HMAC-SHA256, but they don't agree on how to
encode the digest. GitHub, Stripe and Slack send hex; Shopify sends standard
base64 in X-Shopify-Hmac-Sha256, with no prefix to strip.
Air Pipe's is_valid_hmac defaults to hex, so a Shopify check needs encoding: base64:
- value: x-shopify-hmac-sha256
is_valid_hmac:
secret: a|ap_var::SHOPIFY_API_SECRET|
body: a|raw_body|
algorithm: sha256
encoding: base64 # ← without this every genuine Shopify request fails
Leave it off and the check rejects real traffic with invalid hex in signature.
Requires an Air Pipe engine with
encodingsupport onis_valid_hmac(added in 1.33.0).
Endpoints
| Method | Route | Purpose |
|---|---|---|
| POST | /shopify/webhook | Verify the signature, upsert the order. |
| GET | /shopify/orders | Recent orders + total paid revenue (?limit= , default 25). |
| POST | /api/seed | Create the shopify_orders table (from seed.yml). |
Setup
- In the Shopify admin, open your app and copy the API secret key.
- Set the managed variables:
SHOPIFY_API_SECRET— the API secret from step 1.DATABASE_URL— your Postgres connection string.
- Seed the table:
POST /api/seed. - Register your deployed
/shopify/webhookURL for the topics you want:orders/create,orders/updated,orders/paid,orders/cancelled.
Try it
An unsigned or tampered delivery is rejected before any database work:
# Forged signature → 401 invalid Shopify signature
curl -i -X POST https://<your-host>/shopify/webhook \
-H 'content-type: application/json' \
-H 'x-shopify-topic: orders/create' \
-H 'x-shopify-shop-domain: demo.myshopify.com' \
-H 'x-shopify-hmac-sha256: bm90LWEtcmVhbC1zaWduYXR1cmU=' \
--data '{"id":820982911946154500,"order_number":1001,"total_price":"129.95"}'
A correctly signed delivery returns:
{ "status": "ok", "order_id": "820982911946154500" }
Then read the roll-up:
curl 'https://<your-host>/shopify/orders?limit=10'
{ "paid_revenue": 129.95, "paid_orders": 1, "orders": [ ... ] }
Idempotency — why this upserts
Shopify retries a webhook for up to 48 hours until it receives a 2xx, and can deliver
the same event more than once. SaveOrder therefore upserts on shopify_order_id
(ON CONFLICT ... DO UPDATE), so a duplicate delivery refreshes the row instead of
creating a second one. Acknowledge quickly — a slow or non-2xx reply is treated as a
failed delivery.
Signature verification across providers
| Provider | Signed message | Encoding | Prefix | Header |
|---|---|---|---|---|
| Shopify | {raw_body} | base64 | (none) | X-Shopify-Hmac-Sha256 |
| GitHub | {raw_body} | hex | sha256= | X-Hub-Signature-256 |
| Stripe | {timestamp}.{raw_body} | hex | v1= | Stripe-Signature |
| Slack | v0:{timestamp}:{raw_body} | hex | v0= | X-Slack-Signature |
Discord is the odd one out — it signs with Ed25519, not HMAC. See the Discord Interactions pack.
Configuration
config.yml
name: ShopifyWebhooks
docs: true
# A verified Shopify webhook receiver.
#
# Shopify signs every webhook with your app's API secret and sends the digest in
# X-Shopify-Hmac-Sha256
# as **standard base64** — not hex, which is what GitHub, Stripe and Slack use.
# That is why this pack sets `encoding: base64` on the is_valid_hmac test; with
# the default hex decoding the check fails with "invalid hex in signature" on
# every genuine Shopify request.
#
# The handler is idempotent: Shopify retries a webhook for up to 48 hours if it
# does not get a 2xx, and may deliver the same event more than once, so orders
# are upserted on the Shopify order id rather than blindly inserted.
#
# Required managed variables:
# DATABASE_URL — Postgres connection string
# SHOPIFY_API_SECRET — Shopify admin → your app → API secret key
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /shopify/webhook
# Register this URL for the order topics you care about (orders/create,
# orders/updated, orders/paid, orders/cancelled).
shopify/webhook:
output: http
method: POST
summary: Verify a Shopify webhook signature and upsert the order
description: >
Verifies the base64 X-Shopify-Hmac-Sha256 signature over the raw request
body, then upserts the order into Postgres keyed on the Shopify order id so
duplicate deliveries are harmless.
tags: [shopify, webhook, security, ecommerce]
request_example: |-
{ "id": 820982911946154500, "order_number": 1001, "email": "[email protected]",
"total_price": "129.95", "currency": "USD", "financial_status": "paid" }
response_example: |-
{ "status": "ok", "order_id": "820982911946154500" }
actions:
# 1. Expose the Shopify headers (topic + shop domain) for later actions.
- name: ReadHeaders
input: a|headers|
hide_data_on_success: true
# 2. Reject anything not signed by Shopify. `encoding: base64` is the
# critical field — Shopify base64-encodes the digest, and there is no
# prefix to strip (unlike GitHub's "sha256=" or Stripe's "v1=").
- name: VerifySignature
input: a|headers|
run_when_succeeded: [ReadHeaders]
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: "invalid Shopify signature"
tests:
- value: x-shopify-hmac-sha256
is_not_null: true
is_valid_hmac:
secret: a|ap_var::SHOPIFY_API_SECRET|
body: a|raw_body|
algorithm: sha256
encoding: base64
# 3. Upsert the order. Shopify may deliver the same event twice, so the
# Shopify order id is the conflict key.
- name: SaveOrder
run_when_succeeded: [VerifySignature]
database: main
query: |-
INSERT INTO shopify_orders (
shopify_order_id, shop_domain, topic, order_number, email,
total_price, currency, financial_status
)
VALUES (
a|body::id->single_quote|,
a|ReadHeaders::x-shopify-shop-domain->default('')->single_quote|,
a|ReadHeaders::x-shopify-topic->default('orders/create')->single_quote|,
a|body::order_number->default(0)|,
a|body::email->default('')->single_quote|,
a|body::total_price->default('0')->single_quote|::numeric,
a|body::currency->default('USD')->single_quote|,
a|body::financial_status->default('pending')->single_quote|
)
ON CONFLICT (shopify_order_id) DO UPDATE SET
topic = EXCLUDED.topic,
total_price = EXCLUDED.total_price,
financial_status = EXCLUDED.financial_status,
updated_at = NOW()
RETURNING shopify_order_id
post_transforms:
- extract_value: "[0]"
# 4. Acknowledge fast. Shopify treats a non-2xx (or a slow reply) as a
# failed delivery and retries it.
- name: Respond
run_when_succeeded: [SaveOrder]
json_output: |-
{ "status": "ok", "order_id": "a|SaveOrder::shopify_order_id|" }
# GET /shopify/orders
# Recent orders plus revenue, for dashboards.
shopify/orders:
output: http
method: GET
summary: List recent Shopify orders with paid revenue
description: Returns the most recent orders and the total paid revenue.
tags: [shopify, ecommerce, reporting]
actions:
# Query params live in `params` — read them with the a|params| marker.
- name: ReadParams
input: a|params|
hide_data_on_success: true
- name: ListOrders
run_when_succeeded: [ReadParams]
database: main
query: |-
SELECT shopify_order_id, order_number, email, total_price, currency,
financial_status, topic, created_at
FROM shopify_orders
ORDER BY created_at DESC
LIMIT a|ReadParams::limit->default(25)|
- name: PaidRevenue
run_when_succeeded: [ListOrders]
database: main
query: |-
SELECT COALESCE(SUM(total_price), 0) AS paid_revenue,
COUNT(*) AS paid_orders
FROM shopify_orders
WHERE financial_status = 'paid'
post_transforms:
- extract_value: "[0]"
- name: Respond
run_when_succeeded: [PaidRevenue]
json_output: |-
{
"paid_revenue": a|PaidRevenue::paid_revenue|,
"paid_orders": a|PaidRevenue::paid_orders|,
"orders": a|ListOrders|
}
seed.yml
name: ShopifyWebhooksSeed
description: Creates the shopify_orders table (idempotent). Safe to re-run — resets to empty.
docs: true
# Required managed variables:
# DATABASE_URL — Postgres connection string
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"
interfaces:
# POST /api/seed
# Creates the shopify_orders table and indexes, then empties it.
api/seed:
output: http
method: POST
actions:
- name: CreateTable
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS shopify_orders (
shopify_order_id TEXT PRIMARY KEY,
shop_domain TEXT NOT NULL DEFAULT '',
topic TEXT NOT NULL DEFAULT 'orders/create',
order_number INTEGER NOT NULL DEFAULT 0,
email TEXT NOT NULL DEFAULT '',
total_price NUMERIC(12,2) NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'USD',
financial_status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- name: CreateIndexes
database: main
run_when_succeeded: [CreateTable]
hide_data_on_success: true
query: |
CREATE INDEX IF NOT EXISTS idx_shopify_orders_created_at
ON shopify_orders (created_at DESC);
- name: Reset
database: main
run_when_succeeded: [CreateIndexes]
hide_data_on_success: true
query: "TRUNCATE TABLE shopify_orders;"
- name: Respond
run_when_succeeded: [Reset]
json_output: |-
{ "status": "seeded" }
schema.sql
-- Shopify order webhook store. Keyed on the Shopify order id so repeated
-- deliveries of the same event upsert instead of duplicating.
CREATE TABLE IF NOT EXISTS shopify_orders (
shopify_order_id TEXT PRIMARY KEY,
shop_domain TEXT NOT NULL DEFAULT '',
topic TEXT NOT NULL DEFAULT 'orders/create',
order_number INTEGER NOT NULL DEFAULT 0,
email TEXT NOT NULL DEFAULT '',
total_price NUMERIC(12,2) NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'USD',
financial_status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shopify_orders_created_at
ON shopify_orders (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_shopify_orders_financial_status
ON shopify_orders (financial_status);