For the first few months of running my Shopify store, I got my sales data the slow way. I exported a CSV from the admin, dropped it into a folder and ran my analysis scripts. It worked, but the data was always out of date by the time I looked at it.
Webhooks fix that. Instead of you asking Shopify for data, Shopify tells you the moment something happens. When a customer places an order, Shopify sends the full order to a web address you choose, within seconds. In this guide I'll show you how I catch those orders with a small Python app and save each one in a cloud database, where it's safe even if the app restarts.
You don't need to be an experienced developer. If you can install Python packages and run a script, you can follow this.
What you'll need
- Python 3.10 or newer
- A Shopify store where you have admin access
- A free Supabase account (a hosted PostgreSQL database)
- ngrok, free, for testing on your own computer
Step 1: Create a table to hold the orders
In Supabase, create a new project, open the SQL Editor and run this:
create table webhook_events (
id bigint generated always as identity primary key,
webhook_id text unique,
topic text,
shopify_order_id bigint,
order_name text,
payload jsonb not null,
received_at timestamptz default now()
);
alter table webhook_events enable row level security;
A few choices here are worth explaining:
-
payloadis ajsonbcolumn, which stores the whole order exactly as Shopify sent it. Shopify orders have dozens of fields. Rather than decide now which ones matter, keep everything and pick out what you need later. -
webhook_idis markedunique. Shopify sometimes delivers the same webhook more than once, and this lets the database quietly ignore the repeats. We'll use it in Step 3. - Turning on row level security, with no rules added, means nobody can read the table using your public API key. The script uses a private server key instead, which still works.
Step 2: Set up the project
Make a new folder and install three packages:
pip install flask supabase python-dotenv
Then create a file called .env in the same folder:
SHOPIFY_WEBHOOK_SECRET=paste-this-in-step-5
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
You'll find the URL and the service role key in Supabase under Project Settings > API. The service role key has full access to your database, so treat it like a password. Never put it in front-end code, and never commit the .env file to GitHub. Add .env to your .gitignore straight away.
Step 3: Write the listener
Create app.py:
import base64
import hashlib
import hmac
import os
from dotenv import load_dotenv
from flask import Flask, abort, request
from supabase import create_client
load_dotenv()
WEBHOOK_SECRET = os.environ["SHOPIFY_WEBHOOK_SECRET"]
supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
app = Flask(__name__)
def is_from_shopify(raw_body: bytes, signature: str) -> bool:
digest = hmac.new(WEBHOOK_SECRET.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, signature or "")
@app.route("/webhooks/orders/create", methods=["POST"])
def order_created():
raw_body = request.get_data()
if not is_from_shopify(raw_body, request.headers.get("X-Shopify-Hmac-Sha256")):
abort(401)
order = request.get_json()
supabase.table("webhook_events").upsert(
{
"webhook_id": request.headers.get("X-Shopify-Webhook-Id"),
"topic": request.headers.get("X-Shopify-Topic"),
"shopify_order_id": order.get("id"),
"order_name": order.get("name"),
"payload": order,
},
on_conflict="webhook_id",
ignore_duplicates=True,
).execute()
return "", 200
if __name__ == "__main__":
app.run(port=5000)
The most important part is is_from_shopify. Your webhook address will be public, so anyone who finds it could send you a fake order. Shopify signs every webhook it sends with a secret that only you and Shopify know. The function works out what the signature should be and compares it with the one Shopify attached. If they don't match, the request is rejected with a 401 and nothing is saved.
Two details catch beginners out:
- Check the signature against the raw body, not the parsed JSON. The signature was calculated on the exact bytes Shopify sent. Turning them into a Python dictionary and back can change the spacing or the order of the fields, and then the signatures never match.
-
Use
hmac.compare_digest, not==. It takes the same time whether the first character is wrong or the last one is, so an attacker can't guess the signature piece by piece from how long each attempt takes.
The upsert with ignore_duplicates=True goes with the unique webhook_id from Step 1. If Shopify sends the same delivery twice, the second copy is simply skipped.
Step 4: Run it and make it reachable
Shopify can't send anything to a laptop sitting behind a home router, so for testing we'll use ngrok to create a public address that forwards to your computer. In one terminal:
python app.py
In a second terminal:
ngrok http 5000
ngrok prints an address that looks like https://a1b2c3.ngrok-free.app. Keep both terminals open.
Step 5: Tell Shopify where to send orders
In your Shopify admin, go to Settings > Notifications > Webhooks and click Create webhook:
- Event: Order creation
- Format: JSON
-
URL: your ngrok address followed by
/webhooks/orders/create
Once it's saved, Shopify shows the secret your webhooks are signed with, on the same page. Copy it into SHOPIFY_WEBHOOK_SECRET in your .env file, then restart python app.py so it picks up the change.
Step 6: Send a test
Next to your new webhook, click Send test notification. Within a second or two you should see a 200 in the ngrok terminal. Open the Table Editor in Supabase and a new row will be sitting in webhook_events, with the whole order in the payload column.
One warning from my own testing. Shopify's test notification is a fixed sample order, not an empty message. It has a real-looking order number, customer and total, and the one I received was marked as voided. If your analysis reads from this table, filter it out (the payload includes "test": true) or delete the row once you've finished testing. Otherwise a fake sale ends up in your real revenue figures.
Step 7: Take it live
ngrok is fine for testing, but it stops when your laptop does. For real orders, deploy app.py to an always-on host such as Render, Railway or Fly.io. Add the same three values from .env as environment variables in the host's settings, then change the webhook URL in Shopify to your new address.
Keep one Shopify rule in mind as you build on this. Your app has five seconds to reply. If it's slower, or it's down, Shopify counts the delivery as failed and retries up to eight times over four hours. After that it stops and removes the webhook subscription altogether. So keep the listener small: check the signature, save the order, reply. Anything slower, like cleaning the data or updating reports, belongs in a separate script that reads from the table afterwards.
Where I took it next
This small listener became the front door of my store's data platform. Orders land in Supabase first, then a second script turns them into clean rows for my analysis pipeline, which feeds a live dashboard and an API. Keeping "receive" and "transform" as separate steps means that when I find a bug in the transform, I fix it and run it again over the saved orders. Nothing is lost.
If you'd like to see the full version, the code for my project is on GitHub: sunny171p/The-Seamark-Global-Innovations-DataScience-Platform-2.
Top comments (0)