Skip to main content

Shopify Webhooks

Category: Finance & Commerce

Get this pack →

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 encoding support on is_valid_hmac (added in 1.33.0).

Endpoints

MethodRoutePurpose
POST/shopify/webhookVerify the signature, upsert the order.
GET/shopify/ordersRecent orders + total paid revenue (?limit= , default 25).
POST/api/seedCreate the shopify_orders table (from seed.yml).

Setup

  1. In the Shopify admin, open your app and copy the API secret key.
  2. Set the managed variables:
    • SHOPIFY_API_SECRET — the API secret from step 1.
    • DATABASE_URL — your Postgres connection string.
  3. Seed the table: POST /api/seed.
  4. Register your deployed /shopify/webhook URL 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

ProviderSigned messageEncodingPrefixHeader
Shopify{raw_body}base64(none)X-Shopify-Hmac-Sha256
GitHub{raw_body}hexsha256=X-Hub-Signature-256
Stripe{timestamp}.{raw_body}hexv1=Stripe-Signature
Slackv0:{timestamp}:{raw_body}hexv0=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);