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.
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.
| Topic | Means | Arrives |
|---|---|---|
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
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.
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.jsximport { 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.
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.
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.
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:
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.
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_topicsin the production TOML, and deployed. - The handler calls
authenticate.webhookfirst, 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/redactremoves all of that shop's data — tables, files and logs.customers/redactfinds 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