Shopify GDPR webhooks are the three mandatory compliance topics: customers/data_request, customers/redact and shop/redact. Every app listed on the Shopify App Store has to subscribe to them in shopify.app.toml, reject a bad HMAC with 401, acknowledge a valid delivery with a 200-series status, and finish the requested work within 30 days. Custom apps for a single store are not required to.
That is the whole rule set. The detail that is easy to miss is timing: one of the three arrives after your app has already forgotten the shop.
The rules come from Shopify’s privacy law compliance page. Shopify applies one standard to all personal data rather than one per jurisdiction, so the same three webhooks cover GDPR, CPRA and everything else. The page is also explicit that an app has to respond to them “regardless of whether the app collects personal data”. An app that stores nothing still needs the endpoints. It just has less to do when they fire.
What does each Shopify GDPR webhook send, and when?
customers/data_request fires when a customer asks the merchant for their data. You get the customer’s id, email and phone, a list of order ids, and a data_request.id. Your job is to hand whatever you hold about that customer to the store owner directly. There is no API to post it back to Shopify.
customers/redact fires when the merchant asks for a customer’s data to be deleted. Shopify waits 10 days after the request before sending it, and holds it longer if the customer is due to receive a scheduled gift card. The payload carries the customer and an orders_to_redact array.
shop/redact fires 48 hours after the merchant uninstalls your app. It carries only shop_id and shop_domain, and it means: delete everything you have for this shop.
{
"shop_id": 954889,
"shop_domain": "{shop}.myshopify.com",
"customer": {
"id": 191167,
"email": "[email protected]",
"phone": "555-625-1199"
},
"orders_to_redact": [299938, 280263, 220458]
}
Both customer topics only reach apps that were granted access to customer or order data. The ids are numeric REST-style ids, so if your database stores Admin GraphQL GIDs, compare against gid://shopify/Customer/191167 rather than the bare number.
There is one exception to “delete it”. The docs say that if you are legally required to keep data, you should not complete the redaction. Invoice records kept for tax are one example. Delete what you can, keep what the law requires, and write down which is which.
How do you subscribe in shopify.app.toml?
Compliance topics use their own key, compliance_topics, rather than topics. Shopify’s REST webhook reference says they cannot be created through that API either; they only exist in app configuration. The React Router app template ships with them commented out and labelled “Mandatory compliance topic for public apps only”, so a new public app has to uncomment them before it is submitted.
One subscription can carry all three topics to a single route:
[webhooks]
api_version = "2026-10"
[[webhooks.subscriptions]]
uri = "/webhooks/app/uninstalled"
topics = ["app/uninstalled"]
[[webhooks.subscriptions]]
uri = "/webhooks/compliance"
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
Run shopify app deploy to release the configuration. The template uses relative paths like these, and the uri can also be a Pub/Sub address (pubsub://project-id:topic-id) or an EventBridge ARN.
Why does shop/redact fail when you check for a session?
The template’s app/uninstalled handler deletes every session row for the shop. Forty-eight hours later shop/redact arrives, and authenticate.webhook has no offline session to return, so session and admin are both undefined.
The authenticate.webhook reference shows a guard that does if (!session) throw new Response();. Copy that into a compliance route and shop/redact still returns a 200, because an empty Response defaults to that status. Shopify sees a successful delivery, and the shop’s data stays in your database.
So the compliance route must not depend on a session. It needs the shop domain and the payload, both of which arrive with every delivery. HMAC is still checked: authenticate.webhook throws a 401 Unauthorized response when the signature does not match, a 405 for anything other than POST, and a 400 for other validation failures, all before your code runs.
What should the handler actually do?
Record the request and return. Shopify expects a fast answer, and the delivery and retry rules mean the same webhook can arrive more than once, so key the record on webhookId:
model ComplianceRequest {
webhookId String @id
shop String
topic String
payload String
receivedAt DateTime @default(now())
completedAt DateTime?
}
// app/routes/webhooks.compliance.tsx
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import db from "../db.server";
const COMPLIANCE_TOPICS = new Set([
"CUSTOMERS_DATA_REQUEST",
"CUSTOMERS_REDACT",
"SHOP_REDACT",
]);
export const action = async ({ request }: ActionFunctionArgs) => {
// Throws 401 on a bad HMAC before anything below runs.
const { topic, shop, payload, webhookId } =
await authenticate.webhook(request);
if (!COMPLIANCE_TOPICS.has(topic)) {
return new Response("Unhandled topic", { status: 404 });
}
// No session check: shop/redact arrives after uninstall,
// when the session rows are already gone.
await db.complianceRequest.upsert({
where: { webhookId },
create: { webhookId, shop, topic, payload: JSON.stringify(payload) },
update: {},
});
return new Response();
};
A worker then picks up rows where completedAt is null. For SHOP_REDACT it deletes every row keyed on that shop, sessions included, plus anything in logs, caches or third-party tools you pushed shop data into. For CUSTOMERS_REDACT it deletes or anonymises by customer id and by the listed order ids. For CUSTOMERS_DATA_REQUEST it gathers what you hold and sends it to the merchant through whatever channel you already use to reach them. Set completedAt when it is done. That timestamp is your evidence the 30-day deadline was met.
How do you test compliance webhooks before review?
Start with shopify app security check. Its MISSING_COMPLIANCE_WEBHOOKS check flags a shopify.app.toml without the three subscriptions, and --blocking medium makes it fail a CI job.
For the handler, the privacy page points at shopify app webhook trigger, with a caveat worth reading twice: manually triggered webhooks do not test your subscriptions. To prove the two status codes review checks for, sign a request yourself. The library rejects a delivery missing the topic, shop domain, API version or webhook id headers with a 400, so send all of them. The signature is a base64 HMAC-SHA256 of the raw body using your client secret, as covered in our webhook HMAC verification guide:
// scripts/test-compliance.ts
// Run: SHOPIFY_API_SECRET=... node scripts/test-compliance.ts http://localhost:3000/webhooks/compliance
import { createHmac, randomUUID } from "node:crypto";
const url = process.argv[2];
const secret = process.env.SHOPIFY_API_SECRET;
if (!url || !secret) throw new Error("Usage: node test-compliance.ts <url>");
const body = JSON.stringify({
shop_id: 954889,
shop_domain: "example.myshopify.com",
});
const good = createHmac("sha256", secret).update(body).digest("base64");
for (const [label, hmac] of [["valid", good], ["tampered", "bm90LXRoZS1zaWduYXR1cmU="]]) {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Hmac-Sha256": hmac,
"X-Shopify-Topic": "shop/redact",
"X-Shopify-Shop-Domain": "example.myshopify.com",
"X-Shopify-API-Version": "2026-10",
"X-Shopify-Webhook-Id": randomUUID(),
},
body,
});
console.log(label, res.status); // expect: valid 200, tampered 401
}
Run it against shopify app dev with no session for example.myshopify.com in the database. A 200 there shows the route accepts shop/redact after uninstall, which is the case that matters. Then check the row landed in ComplianceRequest.
Our recommendation
For a public app, add the three topics to shopify.app.toml on day one, point them at one route that never asks for a session, and treat the stored request as a job with a deadline. If you store no personal data, the job is a two-line delete. If you build a custom app for one merchant, you can skip all of this, but the moment that app is heading for the App Store the endpoints go in before the listing does.
We build and review Shopify apps for UK merchants and agencies, and compliance webhooks are one of the first things we check when an app is heading for review. Our Shopify development work covers the app backend as well as the storefront.