Shopify app notes · 10

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.

webhooks cron jobs

What a webhook is

Normally your app asks Shopify for things. A webhook is the reverse: Shopify calls your server to say something happened.

order placed → Shopify POSTs to your URL → your route runs

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:

shopify.app.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.

Remember to deploy

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.

TopicFires when
app/uninstalledYour app is removed. Almost every app needs this.
app/scopes_updatePermissions changed.
products/create · products/update · products/deleteA product changes.
orders/create · orders/paidAn order is placed or paid.

The handler

app/routes/webhooks.products.jsx
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.webhook verifies 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.

Trap — slow handlers cause duplicate work

Do something slow inside the handler — call the API, process images, update 500 rows — and this happens:

  1. Your handler starts the work.
  2. Shopify gives up waiting and marks it failed.
  3. Shopify sends the same event again.
  4. 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.

A handler may arrive after the app is gone

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:

server/jobs/syncJob.js
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
}
This is when you add your own server file

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).

server.js — one you create
import { startSyncJob } from "./server/jobs/syncJob.js";

startSyncJob();                // once, at startup
app.listen(PORT);
There is no request, so there is no session

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
  }
}
Trap — this guard only covers one server

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

Trap — jobs run in development too

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();
Uninstalled shops fail forever

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

NeedUse
React immediately to a shop eventWebhook
Do slow work triggered by an eventWebhook records it → job does it
Run on a timetableJob
Retry things that failedJob
Catch events you might have missedJob (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 deploy after adding it.
  • The uri does 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.webhook first — 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, not create).
  • 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 lastError and lastRunAt on 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