How to Connect Shopify to Slack via Webhooks
Connecting a store to an ops channel fails in predictable ways: a slow receiver, a skipped signature check, or deduplication that lives only in process memory. This guide shows how to connect shopify to slack via webhooks so a paid order or fulfillment becomes one Slack message, including when Shopify retries. Shopify does not call Slack. It POSTs to an HTTPS callback you register. That handler verifies the signature against the raw body, stores the delivery, and returns 2xx immediately. A separate worker reads the stored event and calls Slack.
What moves is a topic plus a JSON resource. Shopify sends the topic on X-Shopify-Topic using REST-style names such as orders/paid, even when you created the subscription with the GraphQL Admin API. The body follows the Admin resource for the API version pinned on that subscription, not a GraphQL selection set. Confirm the current payload on the HTTPS webhook delivery docs. Teams usually forward order name, id, total_price, currency, and financial_status, plus X-Shopify-Shop-Domain. Order bodies also include customer email and addresses. Do not put those in Slack unless you have an operational need and the protected-customer-data approval Shopify requires. See privacy law compliance. On the Slack side, chat.postMessage can target a channel per shop and send a text fallback plus Block Kit blocks. An incoming webhook only posts to the channel where it was created, and the URL is the secret.
Use webhooks when a business event should wake a person without polling. Subscribe to a narrow topic such as ORDERS_PAID or a fulfillment topic you have confirmed in the topic enum. ORDERS_UPDATED fires on routine edits, so it is a poor default for a channel alert and will pressure Slack rate limits. Use the GraphQL Admin API to backfill or repair a gap after downtime or a removed subscription, not as the primary notifier. Merchant tools such as Shopify Flow can post a simple alert for one store; they are a weak fit for a multi-tenant app that must verify signatures and route by shop. Creating subscriptions with the legacy REST Admin API webhook resource is not the path to build on. Prefer webhookSubscriptionCreate, or app-configuration subscriptions described in subscribe to webhooks.
Endpoints, Authentication and Limits#
Register subscriptions with the current GraphQL Admin API or with app configuration. Treat delivery as a separate authenticated channel: Shopify signs the raw body, and your worker authenticates to Slack only after the handler has acked. Where a limit is not restated here, the cell links to the vendor page that owns it. Confirm the API version segment on Shopify API versioning before you ship.
| Tool | Endpoint / Event | Auth Method | Rate Limit | Trigger |
|---|---|---|---|---|
| Shopify GraphQL Admin API | webhookSubscriptionCreate on POST https://{shop}.myshopify.com/admin/api/{version}/graphql.json. Example topic: ORDERS_PAID. Confirm the enum on that page. |
X-Shopify-Access-Token with a scope that covers the topic (for orders, read_orders). See authentication and access scopes. |
check the official docs | Your app creates or updates an HTTPS subscription for a shop. |
| Shopify app configuration | Subscriptions declared with the app and deployed for each install. Keys and topic strings: check the official docs. Same delivery URL can serve every shop. | Registration is part of app deploy, not a shop-token call. Deliveries are still signed with the app client secret. See webhooks overview. | check the official docs | Install or deploy registers the subscription Shopify will call later. |
| Shopify webhook delivery | HTTPS POST to your callback. Topic on X-Shopify-Topic. Delivery id on X-Shopify-Webhook-Id. Version on X-Shopify-API-Version. Docs: HTTPS subscriptions. |
HMAC-SHA256 of the raw body, Base64, compared to X-Shopify-Hmac-Sha256. Key is the app client secret, not the access token. |
check the official docs for connection timeout, total request timeout, retry schedule, and removal after repeated failures. | A store event matches a subscribed topic. |
| Slack Web API | POST https://slack.com/api/chat.postMessage. Method reference: chat.postMessage. Block shape: Block Kit blocks. |
Authorization: Bearer bot token. Scope chat:write. Invite the bot, or use a public-channel scope only if Slack scopes still document it for your app. |
check the official docs | Background worker after a verified, deduped Shopify delivery. |
| Slack incoming webhook | POST https://hooks.slack.com/services/... as documented on incoming webhooks. Use only for one fixed channel. |
The URL is the credential. Store it in an environment variable. Do not log it. | check the official docs | Same worker path, when every shop shares one channel. |
The legacy REST Admin API resource at POST /admin/api/{version}/webhooks.json can still appear in old apps. Label it legacy and do not use it as the primary approach. Overview and migration context: REST Admin API. Public apps must also subscribe to mandatory compliance topics and should ack them with the same verify-and-enqueue path. Do not forward redact payloads to Slack. Details: privacy law compliance.
Example Code#
The handler never calls Slack. It checks the HMAC on raw bytes, claims X-Shopify-Webhook-Id in a shared store, enqueues that id, and returns 2xx. The worker builds a message in the same shape as the JSON example and posts it. Secrets come from the environment. The Redis steps are a stand-in for any unique-constrained store; if the claim and the queue push are not atomic, run a sweeper for rows left pending. Retention must outlast Shopify's retry window: check the official docs and set WEBHOOK_RETENTION_SECONDS longer than that window.
import base64
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request
from flask import Flask, abort, request
app = Flask(__name__)
SHOPIFY_API_SECRET = os.environ["SHOPIFY_API_SECRET"]
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_CHANNEL_ID = os.environ["SLACK_CHANNEL_ID"]
REDIS_URL = os.environ["REDIS_URL"]
RETENTION_SECONDS = int(os.environ.get("WEBHOOK_RETENTION_SECONDS", "604800"))
# (a) JSON payload example sent to Slack chat.postMessage.
# Placeholder channel only. The worker fills channel from the environment
# or from a per-shop lookup. Do not hard-code tokens or webhook URLs.
SLACK_PAYLOAD_EXAMPLE = """
{
"channel": "C0123CHANNEL",
"text": "Order #1042 paid - 86.00 USD",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Order #1042* paid - 86.00 USD - Shop: example.myshopify.com"
}
}
]
}
"""
def verify_shopify_hmac(raw_body, header_hmac):
if not header_hmac:
return False
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)
def get_store():
import redis
return redis.Redis.from_url(REDIS_URL)
def save_and_enqueue(webhook_id, topic, shop, api_version, raw_body):
store = get_store()
key = "shopify:wh:" + webhook_id
record = json.dumps({
"webhook_id": webhook_id,
"topic": topic,
"shop": shop,
"api_version": api_version,
"raw_body": raw_body.decode("utf-8"),
"status": "pending",
})
claimed = store.set(key, record, nx=True, ex=RETENTION_SECONDS)
if not claimed:
return "duplicate"
try:
store.rpush("shopify:slack:jobs", webhook_id)
except Exception:
# Drop the claim so a Shopify retry can persist again.
store.delete(key)
raise
return "queued"
@app.post("/webhooks/shopify")
def shopify_webhook():
# (b) Receiver. Read raw bytes before any JSON parser touches the body.
raw_body = request.get_data(cache=False, as_text=False)
signature = request.headers.get("X-Shopify-Hmac-Sha256", "")
if not verify_shopify_hmac(raw_body, signature):
abort(401)
webhook_id = request.headers.get("X-Shopify-Webhook-Id", "")
if not webhook_id:
abort(400)
topic = request.headers.get("X-Shopify-Topic", "")
shop = request.headers.get("X-Shopify-Shop-Domain", "")
api_version = request.headers.get("X-Shopify-API-Version", "")
try:
save_and_enqueue(webhook_id, topic, shop, api_version, raw_body)
except Exception:
abort(500)
return ("", 200)
def build_slack_message(shop, payload):
# Multi-tenant apps should resolve channel from shop, not one global id.
order_name = payload.get("name") or payload.get("id")
total = payload.get("total_price") or ""
currency = payload.get("currency") or ""
text = "Order %s paid - %s %s" % (order_name, total, currency)
text = " ".join(text.split())
return {
"channel": SLACK_CHANNEL_ID,
"text": text,
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*%s*\nShop: %s" % (text, shop),
},
}
],
}
def post_to_slack(message):
req = urllib.request.Request(
"https://slack.com/api/chat.postMessage",
data=json.dumps(message).encode("utf-8"),
headers={
"Authorization": "Bearer " + SLACK_BOT_TOKEN,
"Content-Type": "application/json; charset=utf-8",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
if exc.code == 429:
raise RuntimeError("slack_rate_limited")
raise
if not body.get("ok"):
raise RuntimeError(body.get("error") or "slack_error")
def worker_consume(webhook_id):
store = get_store()
raw = store.get("shopify:wh:" + webhook_id)
if not raw:
return
job = json.loads(raw)
if job.get("status") in ("done", "skipped", "dead"):
return
if job.get("topic") != "orders/paid":
job["status"] = "skipped"
store.set("shopify:wh:" + webhook_id, json.dumps(job), ex=RETENTION_SECONDS)
return
try:
payload = json.loads(job["raw_body"])
except ValueError:
job["status"] = "dead"
store.set("shopify:wh:" + webhook_id, json.dumps(job), ex=RETENTION_SECONDS)
return
post_to_slack(build_slack_message(job["shop"], payload))
job["status"] = "done"
store.set("shopify:wh:" + webhook_id, json.dumps(job), ex=RETENTION_SECONDS)
def worker_loop():
store = get_store()
while True:
item = store.blpop("shopify:slack:jobs", timeout=5)
if not item:
continue
webhook_id = item[1].decode("utf-8")
try:
worker_consume(webhook_id)
except Exception:
# Already acked to Shopify. Retry here, not by failing the HTTP handler.
store.rpush("shopify:slack:retry", webhook_id)
Step-by-Step Setup#
Obtain credentials and scopes#
Create a Shopify app (Partner Dashboard or a custom app) and store the app client secret as SHOPIFY_API_SECRET. That secret signs deliveries. An Admin API access token is needed only if you create subscriptions with webhookSubscriptionCreate. Send it as X-Shopify-Access-Token. Grant the read scope for each topic; order topics need read_orders. Confirm every other scope on access scopes. Token lifetime and refresh rules change: check the official docs.
In Slack, create an app and install it to the workspace. For chat.postMessage, store the bot token as SLACK_BOT_TOKEN and request chat:write. Invite the bot to each target channel. For a single internal channel, an incoming webhook URL in an environment variable is enough; treat a leaked URL as a compromised credential and rotate it. Scope names: check the official docs.
Create the webhook subscription#
Prefer one stable HTTPS callback for every shop, and route inside the handler with X-Shopify-Shop-Domain. Pin an API version from versioning and re-test field mapping when you move it. If every install shares that URL, declare the subscription in app configuration and deploy it. If the callback must be created per shop at runtime, call webhookSubscriptionCreate. Confirm current input fields (HTTPS callback and JSON format) on the mutation page rather than copying a stale example. A skeleton that has stayed stable is below; fill webhookSubscription from that page.
mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
userErrors { field message }
webhookSubscription { id topic }
}
}Use a topic you have confirmed, such as ORDERS_PAID. Read userErrors before you treat the call as success, and store the returned subscription id per shop. The callback must be HTTPS. Do not register the legacy REST webhook resource as the primary method. If the current input supports a field allow-list, use it so customer attributes never reach your server. Exempt this route from CSRF middleware; Shopify does not send a CSRF token.
Map Shopify fields to Slack#
Parse JSON only after the HMAC check, and only in the worker. Branch on X-Shopify-Topic, not on a guess from the body. For orders/paid, map name, id, total_price, currency, and financial_status. Money fields are strings in the REST-shaped webhook body; do not cast them to float. A useful admin link is https://{shop}/admin/orders/{id} using the shop domain header and the numeric id. Keep X-Shopify-Webhook-Id in your store, not in the Slack text.
- Always set Slack
textso notifications still have a fallback if blocks are dropped. Block limits: check the official docs. - Do not map
email,phone,customer,shipping_address, orbilling_addressby default. - Resolve the Slack channel from the shop domain in your database. One global channel mixes merchants.
- Cap line-item text. A large order should not become a huge block payload.
Test deliveries#
Use a development store and place an order that matches the topic. For CLI-triggered sample deliveries, check the official docs and do not assume a flag from an old blog. In unit tests, compute Base64 HMAC-SHA256 over a fixture body with the test secret, set X-Shopify-Hmac-Sha256, and assert the handler returns 200 without calling Slack. Flip one byte and expect 401. Post the same X-Shopify-Webhook-Id twice and expect one queued job. Then run the worker against a Slack test channel and confirm ok is true, not only HTTP 200. Slack often returns 200 with ok: false. See chat.postMessage.
Go live#
Run at least two web processes only if they share the dedup store. In-memory sets split-brain under a second instance. Return 2xx only after the raw event is durable. Alert on HMAC failures, worker exceptions, and Slack ok: false. Log webhook id, topic, shop, and status. Do not log the raw body if it can contain customer data. Watch for subscriptions Shopify stops calling after repeated non-2xx responses; the removal rule is on the HTTPS webhook page. If a subscription is gone, recreate it and backfill with the GraphQL Admin API instead of assuming the gap is empty. Rotate SHOPIFY_API_SECRET by deploying the web process and the worker together. A spike of 401s means a process is still on the old secret.
Common Errors and Troubleshooting#
- 401 or 403 while creating the subscription. The access token is missing, expired, or for another shop, or the scope does not cover the topic. Confirm
X-Shopify-Access-Token, the shop subdomain, anduserErrors. Token rules: check the official docs. A 401 from your own handler is different: the HMAC did not match. Causes include using the access token instead of the client secret, hashing a re-serialized JSON body, a proxy that changed bytes, or comparing hex instead of Base64. Readrequest.get_data()beforeget_json(). Compare withhmac.compare_digest. - 401 or 403 from Slack.
invalid_authmeans a revoked or wrong bot token.not_in_channelmeans the bot was never invited.missing_scopemeanschat:writewas not granted on the installed token.channel_not_foundusually means a channel name from another workspace or a typo in the id. An incoming webhook that returns 403 or 404 was revoked; create a new URL and update the environment variable. Method errors: check the official docs. - 429 rate limiting. GraphQL cost limits apply when you create many subscriptions in a loop. Back off and read Shopify rate limits. Slack returns HTTP 429 or
ok: falsewithratelimited, often withRetry-After. Honor that header in the worker. Do not retry Slack inside the Shopify request. A subscription toORDERS_UPDATEDis a common way to hit this; narrow the topic. Slack tiers: check the official docs. - Signature verification failures. Shopify signs the exact bytes it sent. Frameworks that parse JSON and encode it again will fail verification even when the secret is right. Disable middleware that pretty-prints or reorders keys on this route. A load balancer must not decompress and re-encode the body. Header lookup is case-insensitive, but the documented name is
X-Shopify-Hmac-Sha256. Algorithm details: HTTPS subscriptions. - Duplicate deliveries and idempotency. Shopify retries when you are slow or return non-2xx. Use
X-Shopify-Webhook-Idas the unique key in a store shared by every instance. Ack only after that write. Also make the worker idempotent: if status is alreadydone, do not post again. A crash after a successful Slack post and before the status write can still double-post, becausechat.postMessagehas no vendor idempotency key you should invent. Record an in-progress status and accept that rare duplicates need a human-readable order name. Returning 500 after you already posted will make Shopify retry and worsen the duplicate. - Payload schema mismatches. The body is the REST-shaped resource for the pinned version, not the GraphQL object, even if you subscribed with GraphQL. After a version bump, a field you assumed can disappear or change type. An allow-list you set at subscribe time will omit anything you forgot. Guard with
dict.getand still send thetextfallback. If the worker cannot parse JSON that had a valid HMAC, park the delivery as dead and do not wait for Shopify to fix it. Versioning: check the official docs. - Timeouts and retries. Calling Slack, or any other HTTP API, inside the Flask handler burns the delivery deadline. Shopify's connection timeout, total request timeout, retry schedule, and automatic removal after repeated failures are documented on the HTTPS webhook page; check the official docs and keep the handler well under that deadline. The fix is the split in the sample: persist, return 2xx, post in the worker. If your endpoint is down, Shopify retries on its schedule and may remove the subscription. Recreate it and backfill. Worker timeouts belong in the worker queue, with backoff and a dead-letter list, not in a late 500 to Shopify. Exempting the route from CSRF, and refusing to process a bad HMAC, prevents a different class of timeout where a framework waits on a session that Shopify will never send.