Webhooks & Background Jobs
Reacting when something happens in a shop, and running work on a schedule — without losing events, processing them twice, or hammering the API.
What a webhook is
Normally your app asks Shopify for things. A webhook is the reverse: Shopify calls your server to say something happened.
No polling. You find out within seconds of it happening.
It is just an HTTP POST to a route in your app, with the event data as JSON.
Registering one
You declare which events you want in the TOML:
[webhooks]
api_version = "2026-01"
[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]]
topics = [ "products/update", "products/delete" ]
uri = "/webhooks/products"
Topic = the event name. uri = the route in your app that receives it. Several topics can share one route.
Webhook changes reach Shopify through shopify app deploy. During shopify app dev they apply to your dev store automatically — so "it works locally but not in production" usually means you never deployed.
| Topic | Fires when |
|---|---|
app/uninstalled | Your app is removed. Almost every app needs this. |
app/scopes_update | Permissions changed. |
products/create · products/update · products/delete | A product changes. |
orders/create · orders/paid | An order is placed or paid. |
The handler
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { shop, topic, payload } = await authenticate.webhook(request);
if (topic === "PRODUCTS_UPDATE") {
// react to the change
}
return new Response(); // 200 = "received"
};
// no default export — this is not a page
Three things to notice:
- The filename does not start with
app., so it is not behind the merchant login. Shopify has no login — it is a server calling a server. authenticate.webhookverifies the request really came from Shopify by checking its signature. Never skip this, or anyone could POST fake events at you.- No default export, so it returns data rather than a page.
The golden rule: answer fast
Shopify waits only a few seconds for your 200 response. Take longer and it counts as a failure — and Shopify retries, repeatedly, over the next couple of days.
Do something slow inside the handler — call the API, process images, update 500 rows — and this happens:
- Your handler starts the work.
- Shopify gives up waiting and marks it failed.
- Shopify sends the same event again.
- A second copy of the work starts while the first is still running.
You now have duplicated records and a job queue that never settles.
The pattern: record what happened, respond immediately, do the work afterwards.
export const action = async ({ request }) => {
const { shop, topic, payload } = await authenticate.webhook(request);
// fast: just write a row saying "this needs doing"
await db.pendingTask.create({
data: { shop, topic, productGid: payload.admin_graphql_api_id },
});
return new Response(); // answer in milliseconds
};
A background job then picks the row up and does the slow part. Small updates that take a few milliseconds are fine to do inline — it is the slow work that needs moving.
The same event can arrive twice
Shopify guarantees at least one delivery, not exactly one. Duplicates happen, and events can arrive out of order.
So handlers must be safe to run twice. The technical word is idempotent: running it again changes nothing extra.
// Fragile — two deliveries, two rows
await db.log.create({ data: { productGid } });
// Safe — two deliveries, one row
await db.log.upsert({
where: { productGid },
update: { updatedAt: new Date() },
create: { productGid, shop },
});
Prefer upsert and "set to this value" over "add one to this counter". Deletes should tolerate the row already being gone — which is why deleteMany is easier than delete.
An app/uninstalled webhook can fire more than once, and the token is already revoked by then — so a handler cannot call the Admin API. Keep uninstall handlers to local work only.
And be careful what that local work is: if deleting a shop's session cascades to its other tables, one uninstall can erase everything that shop created. See Note 04 and 05.
Testing without waiting for real events
shopify app webhook trigger
It asks which topic to send and fires a realistic fake payload at your app. You no longer have to uninstall your app to test the uninstall handler, or place an order to test an order handler.
Add a log line at the top of every handler while developing:
console.log(`Received ${topic} for ${shop}`);
Then shopify app logs shows deliveries as they arrive, including in production.
Background jobs
Webhooks react to events. Sometimes you need work that runs on a schedule instead: nightly cleanup, syncing data, retrying failures.
A small scheduler in your server process is usually enough:
import cron from "node-cron";
import db from "../../app/db.server.js";
import { unauthenticated } from "../../app/shopify.server.js";
export function startSyncJob() {
cron.schedule("*/5 * * * *", runPending); // every 5 minutes
}
The template has no server.js — it starts with react-router-serve, which has nowhere to put a scheduler. Adding one is the usual reason to write your own server file, and to change the start script to node server.js (Note 01).
import { startSyncJob } from "./server/jobs/syncJob.js";
startSyncJob(); // once, at startup
app.listen(PORT);
A job at 3am has nobody logged in. Use unauthenticated.admin(shop), which builds an API client from the shop's stored token (Note 04):
const { admin } = await unauthenticated.admin(shop);
This only works with offline tokens — another reason to keep the default.
Writing a job that behaves
1. Stop it overlapping itself
If a run takes longer than the interval, the next tick starts while the first is still going.
let isRunning = false;
async function runPending() {
if (isRunning) return; // skip this tick
isRunning = true;
try {
// ... the work
} finally {
isRunning = false; // always release, even on error
}
}
A variable protects a single Node process. Run two copies of your app — two containers, or a rolling deploy — and both run the job at the same time, each convinced it is alone.
For multiple instances you need a lock in the database (a row updated only if it is currently free). Know which situation you are in before you scale up.
2. Let one shop's failure not stop the rest
for (const row of pending) {
try {
await processShop(row.shop);
await db.task.update({
where: { id: row.id },
data: { done: true, lastRunAt: new Date(), lastError: null },
});
} catch (err) {
// record the reason, leave it pending, move on
await db.task.update({
where: { id: row.id },
data: { lastRunAt: new Date(), lastError: err.message },
});
}
}
Storing lastError on the row is the single most useful debugging decision you can make. Without it, a job that quietly fails for one shop is invisible.
3. Only pick up work that needs doing
Give each row a flag saying whether it has been handled:
const pending = await db.task.findMany({ where: { done: false } });
Mark it done on success, and leave it alone on failure so the next tick retries. The job then does nothing at all when there is nothing to do — which is what you want running every five minutes forever.
4. Go one at a time
Process shops sequentially, not with Promise.all. Batch large updates, and retry when the API throttles you (Note 06). A background job is rarely urgent — being gentle costs you nothing.
Two things that surprise people
Start your dev server and the schedule starts with it, pointed at whatever database your .env names. If that is ever a shared or production database, your laptop is now writing to it every five minutes.
Guard it if that is a risk:
if (process.env.ENABLE_JOBS === "true") startSyncJob();
A job looping over shops will keep hitting revoked tokens for shops that left, failing every tick and filling your logs. Skip shops whose token no longer works, or mark them inactive when a call fails with an auth error.
Choosing between them
| Need | Use |
|---|---|
| React immediately to a shop event | Webhook |
| Do slow work triggered by an event | Webhook records it → job does it |
| Run on a timetable | Job |
| Retry things that failed | Job |
| Catch events you might have missed | Job (a periodic reconcile) |
The last one is worth planning for. Webhooks can be missed — your server may be down during a deploy. A periodic job that re-checks and corrects drift makes the whole system self-healing.
Common problems
My webhook never fires
- You did not run
shopify app deployafter adding it. - The
uridoes not match the route filename. - The route file starts with
app., so the login check rejects Shopify.
Test it with shopify app webhook trigger to separate "not registered" from "handler broken".
It fires repeatedly for the same event
Your handler is too slow or returned an error, so Shopify is retrying. Respond 200 fast and move the work elsewhere.
Duplicate rows from webhooks
Your handler is not idempotent. Switch create to upsert.
My job runs twice per tick
Either two server instances are running, or the scheduler was started more than once. Start it exactly once, at process startup.
The job worked once and never again
Rows were marked done but never reset, or an exception left the isRunning flag stuck at true. Always release it in a finally block.
Checklist
- Handlers call
authenticate.webhookfirst — always. - Webhook files are not named
app.*and have no default export. - Handlers return 200 in milliseconds; slow work is handed to a job.
- Handlers are safe to run twice (
upsert, notcreate). - Jobs have an overlap guard released in
finally. - Jobs process shops one at a time, and one failure does not stop the rest.
- Failures record
lastErrorandlastRunAton the row. - You know whether you run one instance or several.
Cheat sheet
# register (then deploy!)
[[webhooks.subscriptions]]
topics = [ "products/update" ]
uri = "/webhooks/products"
# handler
const { shop, topic, payload } = await authenticate.webhook(request);
return new Response(); # fast 200
# rules
verify first never trust an unverified payload
answer in milliseconds slow work → record it, do it later
be idempotent upsert, not create — duplicates WILL arrive
# jobs
cron.schedule("*/5 * * * *", run) started once in server.js
unauthenticated.admin(shop) no request = no session
isRunning guard, released in finally
find where done: false, mark done on success
record lastError on failure, leave pending to retry
# test
shopify app webhook trigger
shopify app logs