How to connect Shopify to Airtable via Webhooks
How to connect Shopify to Airtable via webhooks is an event-driven handoff: Shopify POSTs a resource to an HTTPS endpoint you control, your service checks the signature, and it writes a row through the Airtable Web API. Shopify does not call Airtable, and Airtable does not subscribe to Shopify topics. The pattern fits a fulfillment queue, a finance log, or a catalog table that people already work in, without anyone refreshing the Shopify admin for each change.
What flows is only the slice you map. An orders/create or orders/updated delivery is the order resource itself, not an event envelope: id, name, email, total_price, currency, financial_status, created_at, and line_items, plus headers such as X-Shopify-Topic, X-Shopify-Shop-Domain, and X-Shopify-Webhook-Id. A products/update delivery is the product resource. Copy identifiers, status, and totals you will filter on. Leave full addresses and payment details out unless a workflow truly needs them. Airtable column names are yours; they are not Shopify fields.
Use webhooks when each new or changed resource should become a row and you can host a public HTTPS callback that verifies HMAC and answers inside the window Shopify documents. Use a scheduled read of the Order or Product resource when you are backfilling history, closing a gap after an outage, or cannot meet that window. Webhooks do not replay the past. Airtable automations and Airtable webhooks run the other direction: they notify you about base changes. Confirm the current subscription method, topic list, and limits in the Shopify webhook docs and the Airtable Web API before you pin a client.
Endpoints, Authentication and Limits#
| Tool | Endpoint / Event | Auth Method | Rate Limit | Trigger |
|---|---|---|---|---|
| Shopify | POST https://{shop}.myshopify.com/admin/api/{version}/webhooks.json — Webhook resource. {version}: check the official docs | Admin API access token in X-Shopify-Access-Token | check the official docs (rate limits) | Create a REST webhook subscription |
| Shopify | POST https://{shop}.myshopify.com/admin/api/{version}/graphql.json mutation webhookSubscriptionCreate — mutation docs | Admin API access token in X-Shopify-Access-Token | check the official docs (rate limits) | Create a GraphQL webhook subscription |
| Shopify | HTTPS POST to your callback; topic orders/create — HTTPS subscriptions | HMAC-SHA256 in X-Shopify-Hmac-Sha256, signed with the app client secret (or the admin signing secret if you registered the webhook there) | check the official docs (webhooks) | Order created |
| Shopify | HTTPS POST to your callback; topic orders/updated — event topics | Same HMAC header and secret as orders/create | check the official docs | Order changed after create |
| Shopify | HTTPS POST to your callback; topic products/update — webhook overview | Same HMAC header and secret | check the official docs | Product updated (REST topic has no trailing d) |
| Airtable | POST https://api.airtable.com/v0/{baseId}/{tableIdOrName} — Create records | Authorization: Bearer personal access token — Authentication | 5 requests per second per base; check the official docs | Insert or upsert mapped fields |
| Airtable | PATCH https://api.airtable.com/v0/{baseId}/{tableIdOrName}/{recordId} — Update record | Bearer personal access token | 5 requests per second per base; check the official docs | Update a row when you already store its record id |
| Airtable | GET https://api.airtable.com/v0/{baseId}/{tableIdOrName} — List records | Bearer personal access token | 5 requests per second per base; check the official docs | Lookup before write if you are not using upsert |
Replace {version} with a version your app is allowed to call. Scopes follow the topic: read_orders for order deliveries, read_products for product deliveries. Confirm scope names on Shopify access scopes. A personal access token must include data.records:write and be granted on that specific base. URL-encode a table name that contains spaces; a table id (tbl…) avoids that. Batch size for create calls: check the official docs.
Example Code#
Read SHOPIFY_API_SECRET, AIRTABLE_TOKEN, AIRTABLE_BASE_ID, and AIRTABLE_TABLE_ID from the environment. The secret is the app client secret used for HMAC, not the Admin API access token. The JSON string is the body sent to Airtable. Field names must already exist on the table. performUpsert is documented on the create-records page; confirm fieldsToMergeOn constraints there before you rely on it. Install Flask and requests yourself; pin versions in your own lockfile.
import os
import hmac
import hashlib
import base64
import json
from flask import Flask, request, abort
import requests
app = Flask(__name__)
SHOPIFY_API_SECRET = os.environ["SHOPIFY_API_SECRET"]
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]
AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"]
AIRTABLE_TABLE_ID = os.environ["AIRTABLE_TABLE_ID"]
# JSON payload example sent to Airtable create-records.
AIRTABLE_PAYLOAD_EXAMPLE = """{
"performUpsert": {"fieldsToMergeOn": ["Order ID"]},
"records": [
{
"fields": {
"Order ID": "5678901234",
"Order Name": "#1001",
"Email": "buyer@example.com",
"Total": "49.00",
"Currency": "USD",
"Financial Status": "paid",
"Created At": "2026-10-15T12:00:00-04:00"
}
}
]
}"""
def verify_shopify_hmac(raw_body, header_hmac):
digest = hmac.new(
SHOPIFY_API_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256,
).digest()
computed = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(computed, header_hmac or "")
def to_airtable_fields(order):
customer = order.get("customer") or {}
# total_price is a decimal string. Do not cast it through float.
return {
"Order ID": str(order.get("id", "")),
"Order Name": order.get("name") or "",
"Email": order.get("email") or customer.get("email") or "",
"Total": order.get("total_price") or "",
"Currency": order.get("currency") or "",
"Financial Status": order.get("financial_status") or "",
"Created At": order.get("created_at") or "",
}
@app.post("/webhooks/shopify/orders")
def shopify_orders_webhook():
raw = request.get_data()
header_hmac = request.headers.get("X-Shopify-Hmac-Sha256", "")
if not verify_shopify_hmac(raw, header_hmac):
abort(401)
topic = request.headers.get("X-Shopify-Topic", "")
webhook_id = request.headers.get("X-Shopify-Webhook-Id", "")
shop = request.headers.get("X-Shopify-Shop-Domain", "")
if topic not in ("orders/create", "orders/updated"):
return ("", 200)
order = json.loads(raw.decode("utf-8"))
fields = to_airtable_fields(order)
fields["Last Webhook ID"] = webhook_id
fields["Shop Domain"] = shop
# Persist webhook_id before side effects in production. Memory is not enough.
payload = {
"performUpsert": {"fieldsToMergeOn": ["Order ID"]},
"records": [{"fields": fields}],
}
url = "https://api.airtable.com/v0/" + AIRTABLE_BASE_ID + "/" + AIRTABLE_TABLE_ID
resp = requests.post(
url,
headers={
"Authorization": "Bearer " + AIRTABLE_TOKEN,
"Content-Type": "application/json",
},
json=payload,
timeout=8,
)
if resp.status_code == 429:
abort(503)
if not resp.ok:
abort(502)
return ("", 200)Step-by-Step Setup#
Credentials#
In the Shopify admin, create a custom app or use your OAuth app, and grant read_orders if you subscribe to order topics and read_products if you subscribe to product topics. Copy two different values: the Admin API access token (sent as X-Shopify-Access-Token when you create subscriptions or reconcile orders) and the API secret key (used only to verify X-Shopify-Hmac-Sha256). Putting the access token in the HMAC function fails every delivery. If you instead register the webhook under Shopify admin notifications, sign with the secret shown there, not with an unrelated app token. Public apps that receive order payloads may also need protected customer data access; confirm on Shopify's protected customer data docs.
In Airtable, create a personal access token with data.records:read and data.records:write, and grant it on the target base. A valid token that was not granted on that base returns 403. Record the base id (app…) and table id (tbl…) from the base API documentation screen. Store SHOPIFY_ACCESS_TOKEN, SHOPIFY_API_SECRET, AIRTABLE_TOKEN, AIRTABLE_BASE_ID, and AIRTABLE_TABLE_ID in the host environment. Do not commit them, and do not write them to request logs.
Creating the webhook subscription#
The callback must be HTTPS and reachable from Shopify. Localhost will not receive deliveries; a tunnel is only for a short test. One subscription covers one topic. orders/create does not include later edits. If the base should track payment or fulfillment changes, also subscribe to orders/updated and upsert on order id. orders/updated is chatty; that is why the handler writes one order row, not one Airtable call per line item, on every delivery.
REST body for the Webhook resource, with header X-Shopify-Access-Token and Content-Type application/json:
{
"webhook": {
"topic": "orders/create",
"address": "https://example.com/webhooks/shopify/orders",
"format": "json"
}
}Topic strings use a slash. products/update is valid; products/updated is not. orders/updated is valid; do not "correct" it to orders/update. GraphQL uses a different spelling: the webhookSubscriptionCreate topic enum looks like ORDERS_CREATE. Confirm the input object on the mutation page rather than translating the REST body yourself. Save the webhook id from the response so you can delete the subscription later through the same Webhook resource. For a multi-store app, HMAC still uses the app client secret, but route X-Shopify-Shop-Domain to the correct base. One global base mixes stores unless that is intentional.
Mapping fields#
Create the Airtable columns before the first POST. A practical order table uses text columns named Order ID, Order Name, Email, Total, Currency, Financial Status, Created At, Last Webhook ID, and Shop Domain. Order ID must be text. Shopify ids are large, and upsert matching is exact. total_price stays a decimal string so currency math is not done with binary float. created_at is an ISO-8601 timestamp; an Airtable date field may reject the offset. Either store it as text or set the typecast option documented on create records after you confirm it. Single-select columns reject a financial_status you did not add as an option; text is the safer default.
line_items is an array of title, sku, quantity, and price. A short text summary fits the order row. A child table means another write per item, or a batch whose maximum size you must confirm in the Airtable docs, and it spends the 5-requests-per-second base budget every time orders/updated fires. customer can be missing on some POS orders; coerce email to an empty string instead of assuming the key exists. The webhook body is the order object. Do not look for a wrapping order or data key.
Testing#
Hash a fixture file of raw bytes with the same secret and compare to a known X-Shopify-Hmac-Sha256 value before you involve Shopify. Then point a development-store subscription at the tunnel and place a test order. You want three results: 401 when the secret is wrong, 200 when the raw body matches, and one Airtable row whose Order ID equals the Shopify order id. Send the same bytes twice. The second delivery must not insert a second row. Send an orders/updated body with the same id and a new financial_status; the same row should change.
If verification fails only behind the tunnel, the proxy is rewriting the body. Hash the bytes your framework received, not a pretty-printed copy. Log topic, webhook id, shop domain, and the Airtable status code. Do not log the bearer token, the client secret, or the full customer payload. Rename an Airtable column on purpose once and confirm you get 422, so the on-call path is familiar.
Going live#
Move the callback to a stable host with a valid certificate, remove tunnel subscriptions, and pin the Admin API version you tested. Persist X-Shopify-Webhook-Id in a store that survives restarts and multiple workers. An in-memory set will double-write after every deploy. Return 2xx only after Airtable has accepted the write, or put the write on a queue and accept that a crash between ack and write needs a reconciliation read. Confirm Shopify's response deadline and retry schedule on the HTTPS webhook docs before you choose. Keep the client timeout shorter than that deadline so you can still return 503.
Alert on verifier 401s and on Airtable 422 or 429. After an outage, do not assume retries filled every gap. List orders for the missing window from the Order resource and upsert them. Keep the field map next to the base so a renamed column is a deliberate change, not a silent 422 in production.
Common Errors and Troubleshooting#
- 401 or 403 from your handler. HMAC mismatch. Usual causes: hashing the access token's value, using the admin notification secret for an app subscription (or the reverse), re-serializing JSON before hashing, or a middleware that consumed the body. Hash the raw request bytes, base64-encode HMAC-SHA256, and compare with hmac.compare_digest to X-Shopify-Hmac-Sha256. Return 401 and do not write to Airtable. A wrong secret makes Shopify retry; fix the secret, then reconcile orders rather than returning 200 to hide the failures.
- 401 or 403 from Shopify while subscribing. 401 means the X-Shopify-Access-Token is missing, revoked, or sent as a Bearer header. Shopify's Admin API token header is X-Shopify-Access-Token. 403 means the token lacks the topic scope, or a public app is blocked on protected customer data. Update scopes, reinstall if required, and confirm access in the protected customer data docs. A 422 on create is often an unknown topic string or a non-HTTPS address.
- 401 or 403 from Airtable. 401: token absent, expired, or not sent as Authorization: Bearer. 403: the token is valid but was not granted on this base, or it lacks data.records:write. Edit base access on the token; do not rotate the Shopify secret for an Airtable 403. See Airtable authentication.
- 429 rate limiting. Airtable documents 5 requests per second per base and returns 429 when you exceed it. orders/updated plus per-line-item writes is the usual spike. Upsert one row per order, stay inside the documented batch size, and back off. Honor Retry-After if the response includes it; otherwise check the rate-limit docs. Return 503 to Shopify so the delivery is retried later. Do not tight-loop inside the request. Shopify 429s on subscription creates or reconciliation reads are Admin API throttling; check Shopify rate limits and space those calls out.
- Signature verification failures that are not a bad secret. Hashing a hex digest, using SHA1, comparing the header to the raw digest instead of the base64 string, or changing whitespace all fail a correct secret. The signed input is the exact body bytes. Empty header means fail closed. A framework that parses JSON and lets you hash a dict will never match.
- Duplicate deliveries and idempotency. Shopify delivers at least once. The same X-Shopify-Webhook-Id can arrive twice, and the same order id arrives on orders/create and many orders/updated events. Dedup on webhook id stops exact retries. Upsert on Order ID stops logical duplicates. Airtable has no Idempotency-Key header; the key has to be a field you control. Confirm performUpsert and fieldsToMergeOn on the create-records page. Process memory is not an idempotency store.
- Payload schema mismatches. Airtable 422 unknown field means a renamed or mistyped column; names are case-sensitive. 422 invalid value means a date field got a zoned timestamp, a number field got an empty string, or a single select got a status you never added. Reading payload order id throws because the body is the resource, not an envelope, and a 500 makes Shopify retry the same bad parse. REST topic products/updated and GraphQL topic orders/create are both wrong spellings for their respective APIs.
- Timeouts and retries. If the handler waits past the deadline Shopify documents, the delivery is a failure and will be retried. Confirm that deadline and the retry schedule in the webhook docs; do not hard-code a retry count from memory. Keep the path to verify, map, and one Airtable call. Set the HTTP client timeout so a slow Airtable response still lets you return 503. Returning 200 when Airtable failed stops retries and drops the row. Returning 5xx is safe only if the write is idempotent. After retries stop, backfill from the Order resource for that time window and upsert.