Shopify app notes · 15

Compliance Webhooks

The three privacy webhooks every App Store app must handle — what each one asks of you, how to register them, and how they fit with keeping data safe on uninstall.

privacy App Store requirement

What these are, and why they exist

Privacy laws give people the right to see the data a business holds about them, and to have it deleted. When a shopper exercises that right with a store, the store has to pass the request on to every app that might hold their data.

Shopify does that passing-on for you, as three webhooks.

TopicMeansArrives
customers/data_request"A customer wants to see what data you hold about them."When the customer asks the store
customers/redact"Delete this customer's data."10 days after the request — or, if they ordered in the last six months, once six months have passed
shop/redact"Delete everything about this shop."48 hours after the shop uninstalls your app

Who has to implement them

public apps

Mandatory

Every app in the Shopify App Store must subscribe to all three and respond correctly — even if it stores no personal data at all.

Missing or broken compliance webhooks get the app rejected in review.

custom apps

Not required

An app for one client does not need them to launch.

Still worth adding if you store customer details, since the underlying privacy obligations are real either way.


Step 1 — register them

In your TOML, using compliance_topics rather than topics:

[webhooks]
api_version = "2026-01"

  [[webhooks.subscriptions]]
  compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ]
  uri = "/webhooks/compliance"

One route for all three is simplest. You can give each its own uri if you prefer separate files.

Then shopify app deploy — as with every webhook, the TOML change does nothing in production until deployed.


Step 2 — the handler

app/routes/webhooks.compliance.jsx
import { authenticate } from "../shopify.server";
import db from "../db.server";

export const action = async ({ request }) => {
  // Verifies the signature. A bad signature throws a 401 — let it.
  const { shop, topic, payload } = await authenticate.webhook(request);

  // Record every request — you need proof you handled it.
  await db.complianceRequest.create({
    data: { shop, topic, payload: JSON.stringify(payload) },
  });

  return new Response();   // 200 = received. Do the slow work afterwards.
};

A small table to hold them:

model ComplianceRequest {
  id          Int       @id @default(autoincrement())
  shop        String
  topic       String
  payload     String                     // add @db.Text on MySQL / PostgreSQL (Note 05)
  handledAt   DateTime?                  // set when the work is done
  createdAt   DateTime  @default(now())
}

Recording the request then answering straight away is the pattern from Note 10: webhooks must reply fast, and deleting a shop's data can take a while. A background job picks up rows where handledAt is null, does the work, and stamps them.

Note this table deliberately has no relation to Session — it must survive the shop being erased, because it is your record that you erased it.

Trap — swallowing the 401

Shopify requires a 401 Unauthorized for requests with an invalid signature. authenticate.webhook does this by throwing a 401 response.

Wrap the handler in a try/catch that always returns 200 — a common "make it never fail" habit — and you have turned forged requests into accepted ones. Review can reject the app for it. If you must catch errors, re-throw anything that is a Response:

catch (err) {
  if (err instanceof Response) throw err;   // keep the 401
  // ...handle genuine errors
}

Step 3 — actually do the work

You have 30 days from receiving a request to complete it.

customers/data_request

The payload tells you which customer and which orders:

{
  "shop_domain": "shop.myshopify.com",
  "customer": { "id": 191167, "email": "john@example.com", "phone": "555-625-1199" },
  "orders_requested": [299938, 280263],
  "data_request": { "id": 9999 }
}

Find everything you hold about that customer and provide it to the store owner, who passes it on. Shopify does not collect it for you — email it to the shop, or make it available to download in your admin.

customers/redact

Delete or anonymise that customer's data — search by the customer id, the email, and the listed order ids.

await db.review.deleteMany({
  where: { shop, customerEmail: payload.customer.email },
});

shop/redact

Delete everything you hold for that shop.

// shop comes from the ComplianceRequest row you saved in step 2
await db.widget.deleteMany({ where: { shop } });
await db.setting.deleteMany({ where: { shop } });
await db.session.deleteMany({ where: { shop } });   // usually already gone

Delete by shop directly. Do not look the shop up through its session first: the template deleted the session at uninstall, 48 hours before this arrives, so the lookup finds nothing and the code quietly deletes nothing — while you still answer 200. This is why every table keeps a shop column (Note 05).

Remember files too — anything you uploaded to storage outside Shopify, and any logs holding personal details.

If you store no personal data

You still subscribe to all three and still return 200. Log the request and mark it handled. The requirement is to respond, even when there is nothing to delete.

When the law says keep it

If you are legally required to retain some data — tax records, for example — then you should not delete that part. Keep only what the obligation covers, and delete the rest.


How this fits with keeping data on uninstall

Note 04 and 05 explained why shop data hangs off a shop column rather than the session row the template deletes on uninstall. The advice was: keep the data, so a reinstall picks up where the merchant left off.

For a public app, shop/redact completes that picture:

uninstall → keep the data → 48 hours pass → shop/redact → now erase it

A merchant who reinstalls within 48 hours loses nothing. One who has truly left gets their data removed, as the law requires.

Both sides are satisfied: accidental uninstalls are recoverable, and data is not kept indefinitely after a merchant leaves.

For custom apps

Without shop/redact, deciding when to erase a departed client's data is up to you and your agreement with them. Write that decision down rather than leaving data forever by default.


Testing

shopify app webhook trigger

Choose each compliance topic in turn and confirm: the request is recorded, the handler returns 200, and your background job completes the work.

Note that triggering a webhook manually tests your handler, not your subscription. After deploying, confirm the three topics appear on the app's active version in the Dev Dashboard.


Checklist before App Store submission

  • All three topics listed under compliance_topics in the production TOML, and deployed.
  • The handler calls authenticate.webhook first, and a bad signature still produces a 401.
  • It returns 200 quickly and does the deletion in the background.
  • Every request is recorded, with the time it was handled.
  • shop/redact removes all of that shop's data — tables, files and logs.
  • customers/redact finds data by customer id, email and order ids.
  • Each topic tested with shopify app webhook trigger.

Cheat sheet

# register
[[webhooks.subscriptions]]
compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ]
uri = "/webhooks/compliance"

# the three
customers/data_request   give the merchant that customer's data
customers/redact         delete that customer's data
shop/redact              delete the shop's data — 48h after uninstall

# rules
required for App Store apps, even with no personal data
bad signature → 401 (don't swallow it)
reply 200 fast, finish within 30 days
record every request as proof