KDD Brands → Store Integration → Privacy
Privacy Policy — Store Integration
This document describes what Store Integration actually does with personal data. It is written from the code, not from a template. Where a control is weak or incomplete it says so — §8 lists the known gaps.
- App
- Store Integration (Shopify app, client_id
6a455a2111c712d240207233490aae62) - Operator
- KDD Brands LTD, registered in England and Wales, company number 16411950.
Data controller for app-operational data. - Registered office
- 71–75 Shelton Street, Covent Garden, London WC2H 9JQ, United Kingdom
- Privacy contact
- privacy@kddbrands.com
- Last reviewed
- 2026-08-17
1. Roles
- The merchant (the Shopify store that installs the app) is the controller of their customers’ personal data.
- Store Integration is a processor acting on the merchant’s instructions, and a controller only for the merchant’s own account/staff data needed to operate the app (see
Sessionbelow). - Shopify remains the system of record. Subscription contracts, orders, customers, addresses and payment mandates live in Shopify, not in this app.
2. What the app stores
All persistent storage is a single Cloudflare D1 database. It contains 44 tables. Every table is scoped by shop (the merchant’s *.myshopify.com domain) — with one documented exception: the shop is optional on EmailDeliveryEvent, because a bounce notification arrives from the mail provider carrying no merchant identity and the send it refers to may already have been deleted. See §2.1.
2.1 Tables containing personal data
| Table | Personal data columns | Whose data | Why it is stored |
|---|---|---|---|
Session | accessToken, refreshToken, refreshTokenExpires, userId, firstName, lastName, email, locale, accountOwner, collaborator, emailVerified, scope, expires | Merchant staff / store owner | Required OAuth session storage for the Shopify app framework. accessToken is a credential, not merely personal data, and is the most sensitive value the app holds. refreshToken is a second credential of the same class and is listed with it: this app uses expiring offline access tokens (mandatory for public apps created on or after 2026-04-01), so accessToken is short-lived and refreshToken is what mints the next one for its own 90-day life. |
SurveyResponse | customerId (numeric Shopify customer ID from the verified session token), orderId, orderName (e.g. #1042), choice, otherText (free text typed by the buyer — can contain anything they type) | Merchant’s customers | Post-purchase survey answers (“how did you hear about us?”), shown back to the merchant as first-party attribution. |
EmailLog | customerId, orderId, contractId, plus template, channel, status, reason | Merchant’s customers — pseudonymous, but keyed to a buyer via customerId/orderId | Sent-notification history: one row per customer-facing notification the app triggers, so the merchant can tell what was attempted and whether it was emitted, skipped or failed. Stores no name, no email address and no message body. |
Profile | email (the identity — a profile can exist with no Shopify account at all), customerId once there is one, firstName, lastName, a free-form properties blob the merchant controls, and the consent trail: consent, consentSource, consentAt | Merchant’s customers, and people who reached checkout without creating an account | The marketing audience. Consent defaults to never — opt-in is the only lawful default under UK PECR / GDPR, and nobody is emailed marketing until they have explicitly subscribed. An explicit unsubscribe is sticky: a routine customer sync from Shopify cannot resurrect it. customers/redact deletes the row outright. |
CampaignRecipient | email, plus the A/B variant and the message ID | Merchant’s customers | Who a campaign reached. customers/redact deletes the row. |
EmailEvent | email, the clicked URL, and any attributed orderId and revenue | Merchant’s customers | Opens and clicks. No IP address, no user agent, no device fingerprint and no location is recorded — those are what turn open tracking into surveillance, and none are needed to answer whether a message landed. Tracking is off by default and switched on per shop, per kind. customers/redact clears the address and the order ID and keeps the row, since the merchant’s rates are computed from it. |
FlowRun | email, plus which step of a marketing automation the person is on and a JSON snapshot of what triggered it | Merchant’s customers, and people who reached checkout without an account | Progress through a marketing flow. customers/redact deletes the row: it is a schedule of future sends, so anonymising it would either send to nobody or keep an erased person in a nurture sequence. |
Suppression | email, reason (unsubscribed / bounced / complained / manual), optional detail such as a bounce code | Merchant’s customers | Addresses the app must not email. This table deliberately survives customers/redact — see §7 — because deleting it would make the app forget the objection and email the person again. It holds an address and a reason, nothing else. |
EmailDeliveryEvent | email, the message ID it relates to, the event type (bounced / complained / delivered / deferred / failed / rejected), a bounce classification (hard / soft) and the receiving server’s diagnostic code (e.g. 5.1.1) | Merchant’s customers | What the receiving mail systems said back about a message the app sent. It exists so a dead mailbox or a spam complaint stops future sending immediately, rather than being discovered one wasted message later. It holds no message content, no subject line, no name and no IP address — only the address, the verdict and the code. The shop is optional here: the notification arrives from the mail provider with no merchant identity attached and is matched back to the send afterwards, so an event whose send row has already been deleted is recorded with no shop. customers/redact deletes these rows, including any with no shop — see §7. |
ContractActivity | contractId, type, summary, detailJson, actor | Pseudonymous — identifies a subscription contract, not a named person | Subscription timeline shown to the merchant (pauses, quantity changes, billing attempts). Carries no customer identifier by design, so customers/redact cannot and does not match it — see §7. |
DunningTracker | contractId, billingCycleIndex, failureReason | Pseudonymous — identifies a subscription contract, not a named person | Prevents duplicate dunning/retry actions for the same failed billing cycle. Contains no name, email, address or card data, but is linkable to a person via Shopify. |
Review | email (never shown publicly — held only to match an order and to let a merchant answer a complaint), authorName (displayed on the storefront, chosen by the reviewer), orderId, plus rating, title and body (free text typed by the buyer) | Merchant’s customers | Product reviews. customers/redact clears email, orderId and replaces authorName with “Anonymous”; the rating and body REMAIN, because a published review is a statement about a product that other shoppers rely on and erasure covers personal data rather than the opinion — see §7. |
ChatConversation | customerId (numeric Shopify customer ID, taken from the verified sub claim of the buyer’s Shopify session token and from nowhere else), orderId, orderName (e.g. #1042), buyerName (only if the buyer typed one — it is never fetched from the Shopify Admin API), plus status, surface (checkout or thank_you) and activity timestamps | Merchant’s customers | One support conversation opened from checkout or the thank-you page. It holds no email address, no phone number, no postal address and no IP address; the identifiers exist so the merchant can tell which order the question is about. New 2026-08-17 — see §2.3d. |
ChatMessage | body — free text typed by the buyer or by the merchant’s staff (capped at 2,000 characters), role (buyer / merchant / system), and the conversation it belongs to | Merchant’s customers and merchant staff | The transcript. Buyers type into a support box, so a body can contain anything they choose to put there, including data they were never asked for. It is never written to a log line. New 2026-08-17 — see §2.3d. |
CustomerDataRequest | customerId and/or email (both optional, because Shopify’s payload is — a guest checkout has an email and no customer ID), Shopify’s request ID, the requested order IDs as a JSON string, and a status | Merchant’s customers | The queue of unanswered customers/data_request webhooks, so a merchant can be shown which access requests they owe (§7). It holds the request, never the answer: the assembled export is not stored anywhere. Bounded to 30 days. New 2026-08-17. |
JobQueue | parametersJson — the serialised parameters of a delayed job, which may transiently include a shop domain, a subscription contract ID or an order ID | Pseudonymous | Durable retry/scheduling of delayed work. Rows are deleted when the job runs (or flagged as dead-lettered on repeated failure — see §5). |
2.2 Tables containing merchant configuration only (no personal data)
BillingSchedule, Survey, TrackingSettings, CarrierAlias, PostPurchaseFlow, CheckoutDiscountRule, Appearance, Funnel, FunnelStep, RetentionReason, RetentionOffer, DisputeSettings, HeatmapSettings, ReviewSettings, SplitTest, NotificationSettings, Flow, FlowStep, EmailTemplate, Campaign, SetupState, PublicationSettings. These hold settings the merchant types into the app admin — plan hours and timezone, survey questions and choice lists, tracking page copy and carrier display names, offer and discount rules, design tokens, upsell funnels, cancellation offers, split-test definitions, whether the merchant has dismissed the setup checklist, and which sales channels newly added plan products should be published to. They contain no data about identifiable customers.
2.3 Behavioural tables — on-page behaviour, mostly anonymous
These record what happened on a page rather than who a person is. Two of the six are genuinely anonymous; the other four are not, and saying otherwise would be the easiest false claim in this document to make:
HeatmapPageViewandHeatmapClickhold no identifier of any kind — no customer ID, no order ID, no email, no IP, no user agent, no referrer. Their IDs are random per-visit and per-page-view values that die with the tab.SplitEvent,FunnelEvent,RetentionSessionandDisputeEventdo hold identifiers this app can resolve to a person: a Shopify customer ID, a Shopify order ID, or a checkout reference ID. The app holds merchant Shopify Admin API tokens, so it can turn any of those into a named customer with one API call. They are pseudonymous, not anonymous, andcustomers/redactclears them — see §7.
| Table | What it holds | Whose | Why |
|---|---|---|---|
HeatmapPageView, HeatmapClick | No identifying columns. Random per-visit and per-page-view IDs, page path (query string removed), viewport size, scroll depth, dwell time, click coordinates as thousandths of the page, and structural element selectors — never an element’s text or value. | Storefront and, since 2026-08-17, checkout visitors — anonymous | Aggregate heatmaps shown to the merchant. Off by default. Storefront collection requires two separate merchant opt-ins: enabling collection in the app AND placing the app embed in the theme. Never recorded on account, order-status or admin pages. Checkout is measured by a different collector and needs its own second switch — enabling collection in the app AND ticking checkout — and is a much narrower surface: the only page address ever stored there is one of six fixed step names, never the checkout URL, its token or an order number — see §2.3c. |
SplitEvent | A random per-browser visitor ID, which test and variant were shown, and — on a conversion — a real Shopify orderId and the order value. | Storefront visitors — anonymous on a view, pseudonymous on a conversion (the order ID resolves to a buyer via Shopify) | Measuring which version of a page performs better. The visitor ID exists so one person’s refresh is not counted as ten views. customers/redact resets orderId to the empty-string sentinel. |
FunnelEvent | referenceId from the verified post-purchase token, the offered variant, and whether the buyer accepted or declined. | Pseudonymous — keyed to a checkout, resolvable to a buyer via Shopify | Reporting on post-purchase offers. customers/redact overwrites referenceId (the column cannot be null). |
RetentionSession | contractId, optional customerId (a real Shopify customer ID), the cancellation reason chosen and which offer was accepted. | Pseudonymous — the customer ID resolves to a named person via Shopify | Showing the merchant which save offers work. customers/redact nulls customerId. |
DisputeEvent | disputeId, orderId, orderName (e.g. #1044), optional customerId, dispute status and amount, and which subscriptions the app paused. | Pseudonymous — all three identifiers resolve to a named person via Shopify | So a merchant can see a chargeback and what the app did about it. customers/redact nulls customerId, orderId and orderName. |
2.3a Order data received for email attribution
Since 2026-07-30 the app subscribes to Shopify’s orders/create webhook. It exists for one purpose: to tell a merchant whether an email they sent led to a sale.
What the webhook delivers is a full order payload. What the app keeps from it is deliberately almost nothing:
- It reads the buyer’s email address and the order total, uses them to look for a click that address made in the last 7 days, and — if it finds one — writes the order ID and the total onto that existing
EmailEventrow. That is the whole write. - Nothing else from the order is stored. Not line items, not products, not quantities, not the shipping or billing address, not the phone number, not the customer name, not discount codes, not the payment method. The payload is read in memory and discarded.
- No new row is created. If the address has no recent tracked click, the webhook does nothing at all and leaves no trace.
- The 7-day last-click window is a model, not a measurement. It records that someone clicked an email and later bought; it does not establish that the email caused the purchase, and the app’s reporting says so.
Attribution only produces data for campaign sends. A flow or transactional send records no recipient address on its EmailEvent, so those clicks cannot be matched to an order — see §8.
2.3b Suppression is shared across the merchants on this app
Suppression rows are stored per shop, but two of the four reasons are read across every shop the app serves:
bouncedandcomplainedare account-wide. Every merchant on this app sends from one onboarded domain (kddbrands.com). A hard bounce or a spam complaint is a fact about the address and about that shared sending reputation, so once any shop records one, every shop stops sending to that address. Not doing this would mean one merchant’s dead address damaging deliverability for all the others.unsubscribedandmanualstay strictly per shop. Unsubscribing from one merchant is not unsubscribing from another, and a merchant suppressing someone by hand does not speak for anyone else’s list.
The practical consequence, stated plainly: the app can decline to send a transactional message on behalf of merchant B because merchant A’s send to the same address bounced. No merchant can see another’s suppression list — the sharing is inside the app’s send decision, not in any screen or export.
Since 2026-08-05 these suppressions are also created from delivery events (§2.1, EmailDeliveryEvent) rather than only from a send being refused. A hard bounce or a spam complaint suppresses the address at once; a soft bounce — a full mailbox, a server asking us to try later — only after three of the last five outcomes for that address were soft bounces, so a mailbox that recovers is never suppressed at all. The rolling window is measured across merchants, for the same reason the suppression itself is: a full mailbox is a fact about the mailbox, not about which shop happened to write to it.
2.3c Checkout heatmaps — a second collector, added 2026-08-17
Until 2026-08-17 heatmaps were a storefront-only feature, delivered by a theme app embed. A theme app embed cannot run on checkout, so checkout was simply out of reach. It is now reachable, by a web pixel app extension running in Shopify’s strict sandbox. This section describes that collector specifically. It writes to the same two tables as the storefront collector — HeatmapPageView and HeatmapClick — and adds no new column and no new table.
It has its own opt-in, and the two switches are conjunctive. Nothing is collected on checkout unless the merchant has switched on heatmap collection and checkout collection. Both default to off. The two are combined on read and on write, so switching the module off switches checkout off with it and no stale “on” can survive. Turning checkout off does not merely ask the collector to stop: the app deletes the Shopify web pixel record, so nothing is running in the buyer’s browser at all. And the ingest endpoint re-checks the switch regardless, because a Shopify-side delete can fail — the app drops any checkout batch for a shop whose checkout switch is off.
The four content-bearing events are refused, and that is the whole basis of the claim below. Shopify’s Advanced DOM Events API would hand this app a DOM node carrying attributes (including value), textContent and checked. On checkout those sit on the fields holding a buyer’s name, email, postal address and card details. The extension subscribes to four events and no others:
| Subscribed | What it yields |
|---|---|
page_viewed | A standard event, needing no special permission. Used only to notice that the checkout step changed. |
advanced_dom_clicked | Click position, plus a structural selector for the element. |
advanced_dom_scrolled | How far down the page the buyer reached. |
advanced_dom_window_resized | The size of the browser window. |
| Not subscribed, deliberately | Why |
|---|---|
advanced_dom_available | Publishes a fragment of the whole document: every element’s attributes (including value) and every text node’s textContent. On checkout that is the buyer’s name, address, email and card form. |
advanced_dom_changed | The same payload, continuously, as the page mutates. |
advanced_dom_input_changed | Input-level by definition — there is no version of it that does not describe what a buyer typed. |
advanced_dom_selection_changed | The text the buyer highlighted, on a page made of their own data. |
Subscribing and then discarding would still mean the payload crossed into this app’s code, so the subscription is never made.
Even on the events it does take, only geometry survives. One small module is the only code that touches an event payload, and it reads exactly two attributes — id and class — plus the presence (never the value) of a short allowlist of data-* names. value, name, placeholder, aria-label, title, alt, autocomplete and href are never read. Anything a buyer can type into (input, textarea, select, option, anything editable) contributes its tag name and nothing else — checked before any attribute is read, so on checkout not even which field was clicked is recorded. textContent and checked are never read anywhere in that module.
The buyer’s checkout token never leaves the browser. A real checkout URL is /checkouts/cn/<token>/information — the token is in the path, and normalising such a path would store a per-buyer secret in a path column. So a checkout page address is never normalised. The pixel maps the last path segment through a fixed table and sends one of six constant strings; the server then accepts a checkout batch only if its path is literally one of those six values. The six are /checkout, /checkout/information, /checkout/shipping, /checkout/payment, /checkout/processing and /checkout/thank-you. A checkout step the table does not recognise degrades to the bare /checkout. Only one of six fixed values is ever stored, so there is no free text on the checkout surface anywhere in this system — not on the wire, not in the database, not on the merchant’s screen.
The IDs are the app’s own, not Shopify’s. The per-visit and per-page-view IDs are random 128-bit values generated inside the pixel. Shopify’s own clientId — an identifier that persists across visits — is deliberately never copied into a batch, and neither are the event envelope’s ID, timestamp or sequence number.
Shopify’s own limit, stated because merchants will ask. advanced_dom_* events fire on checkout only for Shopify Plus stores. On every other plan the pixel is installed and simply never receives a checkout event, so a non-Plus merchant who switches checkout collection on will see nothing. Storefront, thank-you and customer-account surfaces are not Plus-gated. The permission itself (read_advanced_dom_pixel_events) is granted by Shopify through a Partner Dashboard request.
On consent: the extension declares itself to Shopify’s customer-privacy machinery as analytics only — analytics = true, marketing = false, preferences = false, sale_of_data = "disabled". The Do Not Track / Global Privacy Control check described elsewhere in this document is implemented in the storefront collector; the pixel runs in a sandbox where consent is enforced by Shopify against that declaration rather than by a check in this app’s code.
What is stored, and what is not. Stored: the six-value step name, viewport size, scroll depth, dwell time, click coordinates as thousandths of the page, structural selectors, and the two random IDs. Not stored — anywhere, on this surface: keystrokes, form-field values, page text or HTML, the checkout URL or token, an order ID or order name, a customer ID, an email address, a name, a postal address, a phone number, card data, or an IP address. The ingest endpoint reads the caller’s IP address to build a rate-limiting key held in memory for a few minutes; it is never written to a column, not even hashed.
Retention is unchanged and is the same weakness as on the storefront: each row is stamped with an expiry of its creation date plus the shop’s retention setting (default 30 days, maximum 90) and nothing acts on that stamp automatically. See §5 and §8.
2.3d In-checkout chat — added 2026-08-17
A buyer can now open a support conversation from checkout or from the thank-you page. This is the first module in the app that stores free text a buyer typed in an open box, addressed to a person. The tables are ChatConversation and ChatMessage, both enumerated in §2.1.
What is stored: the message text (buyer’s and merchant’s alike, capped at 2,000 characters), which side wrote it, the order the conversation is about (orderId/orderName, thank-you surface only — at checkout there is no order yet), the Shopify customer ID when the buyer is signed in, and a buyerName only if the buyer typed one. The customer ID comes from the verified sub claim of a Shopify session token and from nowhere else; the shop comes from the token’s dest claim and never from a query parameter or request body.
What is not stored: no email address, no phone number, no postal address, no card data, no IP address, and nothing fetched from the Shopify Admin API to enrich the conversation. A message body is never written to a log line — application logs are not a store for buyer text.
Who can read it: the merchant’s own staff, in the app admin. That screen is behind Shopify session-token authentication like every other admin screen, so only staff with access to that Shopify store can read that store’s conversations. Buyers reach only their own conversation, over a short-lived per-connection ticket. The app operator has the same broad database access described in §4, and transcripts are inside that scope.
Lifetime. A conversation lives for as long as the shop is installed. There is no age-based expiry, no purge job, and no delete button — closing a conversation in the inbox sets its status to closed and deletes nothing. The only two things that remove a transcript are the two redaction webhooks, and both are wired:
customers/redactdeletes the buyer’s conversations and their messages. The messages are deleted first and explicitly: they key to their conversation by a plain string with no foreign key, so deleting the conversation alone would orphan the buyer’s words rather than erase them.shop/redactdeletes both tables for the shop.
Both are covered by automated checks that read the database schema rather than a maintained list. The honest limitation is in §7 — a chat conversation is matched on customer ID or order ID, and it has no email column, so a guest buyer whose redaction payload carries only an email address is not reached.
2.4 What the app deliberately does not store
- The branded tracking page persists nothing at all. The app queries the Shopify Admin API live on each lookup, compares the supplied email or postcode in memory, and returns a masked result. No order contents, customer name, address, phone number or email address from that query is written to the database — not the lookup itself, and not the fact that it happened. This is a statement about the tracking lookup specifically, not about the app as a whole: the app does persist a small number of buyer identifiers and, in three tables, free text the buyer typed themselves — all of them enumerated in §2.1 and §2.3. Concretely, and to leave nothing implied: it asks for and stores no postal address, no phone number, no order line items or contents, and no date of birth — there is no column for any of them anywhere in the database. One caveat, stated because it would otherwise be a false comfort: the three free-text fields a buyer fills in themselves (
SurveyResponse.otherText,Review.title/Review.body, andChatMessage.body) accept whatever the buyer types, so a buyer who writes their address into a support message has put it there. The app does not ask for it, does not parse it out and does not use it, and it is erased with the rest of that row on redaction.
It does store buyer email addresses, in eight tables — Profile, CampaignRecipient, EmailEvent, FlowRun, Suppression, EmailDeliveryEvent, Review.email and, since 2026-08-17, CustomerDataRequest.email — each enumerated with its purpose in §2.1. Those are every table with an email column except Session, which holds the merchant’s staff address rather than a buyer’s. Alongside those: a reviewer-chosen authorName, a buyer-typed ChatConversation.buyerName, Shopify customer and order IDs, and the buyer’s own free text in SurveyResponse.otherText, Review.title/Review.body and ChatMessage.body. The chat body is the widest of those by nature: a support box invites a buyer to type anything, including data nobody asked them for. Separately, and as a controller rather than a processor, it stores the merchant’s own staff name and email in Session (§2.1), and staff-authored chat replies in ChatMessage.
Corrected 2026-08-05. Until this date the paragraph above read “It does store buyer email addresses in one place only —
Review.email”. That was written before the marketing modules existed and was never revised, so it contradicted §2.1 of this same document, which had listed the other tables all along. The storage always matched §2.1; only this summary was wrong. It is recorded rather than quietly edited because a privacy notice that understated what it held is exactly the kind of change a reader is entitled to see.
- No card or bank data, ever. Payment mandates are Shopify’s; the app can only ask Shopify to send the customer a payment-update email.
- No advertising identifiers, no cross-merchant profiling, and no third-party analytics or tracking pixels. Data is never sold.
Data is not combined across merchants with one disclosed exception, set out in full in §2.3b: whether an address has hard-bounced or complained, and the rolling count of its recent soft bounces, are read across every shop on this app. That is deliverability protection for one shared sending domain, not profiling — it decides only whether to stop sending, is never surfaced in any screen, export or report, and no merchant can see another’s list. It is called out here rather than left to §2.3b so this bullet cannot be read as a blanket denial.
The app’s own optional heatmaps and split-testing features (§2.3) record on-page interaction data when a merchant explicitly enables them — wholly anonymous for heatmaps, and anonymous for split-test views but carrying a real order ID on a split-test conversion. Both are first-party, stay in this app’s own database, are never shared, and are described in full in §2.3.
Since 2026-08-17 the heatmaps feature includes a web pixel app extension (§2.3c). It is the app’s own pixel, not a third party’s: it sends only to this app’s ingest endpoint, carries no advertising identifier, and is declared to Shopify as analytics with sale_of_data = "disabled". This bullet’s denial of “third-party analytics or tracking pixels” remains true as written, and the distinction is spelled out here rather than left to be inferred.
3. Where the data is, and how it is protected
- At rest: Cloudflare D1. Cloudflare encrypts D1 data at rest. The app adds no application-level (field) encryption — including on the merchant’s Shopify access token. This is a known weakness and is recorded as such in §8.
- In transit: HTTPS/TLS 1.2+ everywhere. The app is served only over HTTPS and sets
Strict-Transport-Securityplus a restrictiveContent-Security-Policy. - Region: Cloudflare’s global network; the database has a primary region chosen by Cloudflare at creation. The app does not pin a jurisdiction, so data may be processed outside the merchant’s country. Cloudflare’s standard contractual clauses / DPA apply.
4. Who can access it
- The merchant, through the embedded Shopify admin. Every admin screen is behind Shopify session-token authentication, so only staff with access to that Shopify store can read that store’s data.
- The app operator — currently a single operator — via Cloudflare dashboard/CLI access to the database and application logs, and via a privileged admin endpoint gated by a bearer secret. There is no separate staff tier and no per-record access log. Stated plainly: operator access is broad and audited only by Cloudflare’s own account audit log.
- No other party, except the sub-processors in §6.
Isolation between merchants is enforced by shop-scoped queries in application code, not by separate databases. Every table carries a shop column and every read and write filters on it.
5. Retention
Summarised here; the authoritative, per-table version is the Data Retention Policy.
Session— deleted on app uninstall and again onshop/redact.SurveyResponseandEmailLog— kept until the merchant deletes them, until acustomers/redactwebhook matches them, or untilshop/redact. No automatic age-based expiry.ContractActivity— kept for the life of the install, deleted onshop/redactonly.JobQueue— deleted when the job runs; dead-lettered rows are kept indefinitely for inspection.HeatmapPageViewandHeatmapClick— every row is stamped with an expiry at write time from the shop’s retention setting (default 30 days, maximum 90). The same applies to checkout rows: the web pixel writes to the same two tables and gets the same stamp. That stamp does not expire anything on its own, and nothing in this app runs on a schedule to act on it. The purge is reachable only through the operator’s bearer-gated admin endpoint — i.e. only when the operator runs it by hand. The app’s hourly scheduled task dispatches recurring billing and the delayed-job sweep and nothing else. No table in this app has automatic, scheduled expiry. Until an operator purges, or ashop/redactarrives, heatmap rows persist past their stamped expiry. This is a real gap and is repeated in §8.CustomerDataRequest— the only table in the app with a real, enforced age limit: 30 days from the request, after which the row is pruned on the next redaction or data request for that shop. Also deleted by acustomers/redactfor the same person, and byshop/redact.ChatConversationandChatMessage— kept for the life of the install. No age-based expiry, no purge job and no delete control in the inbox; closing a conversation only changes its status. Deleted oncustomers/redact(both tables, messages first) and onshop/redact.Review— kept until the merchant deletes it orshop/redact. Acustomers/redactdoes not delete the review: it clears the email and the order link and replaces the author name. See §7 for why.- All other tables — kept for the life of the install, deleted on
shop/redact.
6. Third parties (sub-processors)
| Party | What it receives | When |
|---|---|---|
| Shopify | Everything — it is the source and destination of order, customer and subscription data. | Always. |
| Cloudflare | Hosting, the database, request/application logs, the live-message transport that carries chat between a buyer and the merchant’s inbox, and outbound operational alert email. | Always. |
| Klaviyo | Only if the merchant’s shop domain is present in the app’s per-shop Klaviyo configuration. Receives a subscription lifecycle event containing the customer’s email address as the profile identifier, plus event properties. If the shop is not configured, nothing is sent to Klaviyo. | Per lifecycle event, opt-in per shop. |
No other outbound destination exists. There is no analytics vendor, no error tracking SaaS, and no CDN other than Shopify’s own for its admin UI assets.
7. Deletion and data-subject requests
The app implements Shopify’s three mandatory compliance webhooks. All three are HMAC-verified before any work happens, so a forged request cannot trigger a deletion.
customers/redact and customers/data_request
One handler serves both topics, but since 2026-08-17 it answers them differently, and that distinction is the whole point: customers/redact erases, customers/data_request reads and deletes nothing. Until that date the handler never branched on the topic, so an access request under Article 15 ran the deletion path — the worst available inversion of the two rights. That is fixed and is described at the end of this section.
For customers/redact the handler builds a match on the payload’s customer ID and on any entry in orders_to_redact, then, for that shop:
Deleted outright — the row exists only because of that buyer:
- every matching
SurveyResponserow, - every matching
EmailLogrow, - every matching
ChatConversationand everyChatMessageinside it. A transcript is the buyer’s own free text about their own order and no merchant statistic is computed from it, so there is nothing to preserve by erasing in place. The messages are deleted first and explicitly: they key to their conversation by a plain string with no foreign key, so deleting the conversation alone would orphan the buyer’s words rather than erase them, - the
Profile— name, properties and consent trail. Nothing is computed from it, so there is nothing to preserve by erasing in place, - every
FlowRun. A run is a schedule of future sends, so anonymising it would either send to nobody or keep an erased person inside a nurture sequence they have just asked to be removed from, - every
CampaignRecipient. A pending row is a scheduled send, and the uniqueness constraint on (campaign, email) makes a blanking sentinel collide for anyone erased from two campaigns. The cost is that a completed campaign’s “sent” count drops by one, which is the right trade against holding the address of someone who asked to be forgotten, and - every
EmailDeliveryEventfor that address, including rows with no shop. A missing shop means the send it referred to was already gone, so no merchant’s data is being touched — and an erasure request is about the person, not the tenant. These are deleted rather than erased in place because blanking the address would not anonymise them, it would corrupt them: the soft-bounce window groups by address, so every erased person would share one empty-string bucket and their unrelated bounces would pool into a threshold nobody crossed. Nothing is lost by deleting — no merchant-facing statistic is computed from the table, and theSuppressionrow it produced survives (below), so the protection outlives the erasure while the evidence behind it goes.
Matched by email rather than customer ID where the table is email-keyed (Profile, FlowRun, CampaignRecipient, EmailDeliveryEvent), because those records can exist for someone who reached checkout without ever creating an account.
Identifier erased, row kept — these rows are what a merchant’s save rate, dispute history and split-test results are computed from, so deleting them would silently rewrite the merchant’s historical numbers as a side effect of one customer’s erasure request. Erasure covers the personal data, not the fact that a cancellation flow or a chargeback happened:
RetentionSession.customerId→NULL, matched on customer ID.DisputeEvent.customerId,.orderIdand.orderName→NULL, matched on customer ID or order ID.orderNamegoes too: “#1044” identifies a purchase as surely as the full ID does.SplitEvent.orderId→'', matched on order ID. The empty string rather thanNULLbecause that is the “no order” sentinel the view-dedupe uniqueness constraint depends on — the database treats multiple nulls as distinct.FunnelEvent.referenceId→'redacted', matched on order ID. The column cannot be null and nothing re-derives a variant from it after the fact.EmailEvent.email→''and.orderId→NULL, matched on email. These are what a merchant’s open and click rates are computed from, so deleting them would rewrite their historical campaign numbers as a side effect of an unrelated person’s request. Erasure covers the address, not the fact that a click happened. (ContrastEmailDeliveryEventabove, where blanking would corrupt a rolling window rather than merely anonymise a row — the treatment differs because the consequence does.)Review— see below.
Both the bare numeric ID and the full Shopify ID form are matched. The webhook payload is never logged — it is the personal data — and neither is the export a data request assembles.
Review rows are redacted, not deleted, and matched BY EMAIL. By email because a reviewer may have checked out as a guest and have no customer record at all, so a customer-ID match would miss them. Redacted rather than deleted because a published review is a statement about a product that other shoppers are relying on when they buy: the right to erasure covers the personal data in it, not the opinion. So the email and the order link are cleared and the author name becomes “Anonymous”, while the rating and the text remain. A merchant who wants the review gone entirely can hide or delete it in the app.
What a customer redaction does NOT reach. This list is exhaustive as of the 44 tables in the database, and it is a list of real residual data, not a list of things that happen to be empty. An automated check derives the other half of the claim from the schema rather than from a maintained list: it reports fourteen tables holding a buyer identifier, and fourteen reached as of 2026-08-17.
ContractActivityandDunningTracker— keyed bycontractIdonly. A subscription contract belongs to a buyer in Shopify, so these rows are linkable to the redacted customer even though the redaction cannot match them. Removed only byshop/redact.JobQueue.parametersJson— a pending or dead-lettered delayed job may carry a contract or order ID inside its serialised parameters. Not matched (the IDs are inside a JSON string, not a column). Normal rows delete themselves when the job runs; dead-lettered rows persist untilshop/redact.HeatmapPageViewandHeatmapClick— genuinely out of scope: they hold no customer ID, no order ID and no email, so there is nothing to match on. That is a consequence of collecting nothing identifying, not an omission. It also means a redaction cannot remove a redacted customer’s page views, because the app never knew which ones were theirs. This stays true for the checkout rows written by the web pixel (§2.3c): they carry the same columns, one of six fixed step names as the path, and no identifier of any kind.- The matching above is asymmetric, because the tables are.
RetentionSessionhas noorderIdcolumn, so it is matched on customer ID only: a payload carryingorders_to_redactbut no customer ID leaves it untouched.SplitEventandFunnelEventhave nocustomerIdcolumn, so they are matched on order ID only: a payload carrying a customer ID but an emptyorders_to_redactleaves that buyer’s split-test and funnel rows intact. Of the erase-in-place tables onlyDisputeEventis reachable either way; the delete-outright tablesSurveyResponse,EmailLogandChatConversationcarry both columns and are matched on both. - A chat transcript is matched on customer ID or order ID, never on email, because
ChatConversationhas no email column — it never asks a buyer for an address. A buyer who opened a chat at checkout while signed out has no customer ID on the row and, at checkout, no order either; a redaction payload for that person carries an email and nothing this table can match, so their transcript survives. It is removed byshop/redact, or by hand. This is the cost of not collecting an email address in the module, and it is stated rather than left to be discovered.
customers/data_request — the access answer, and it deletes nothing. The handler assembles an export covering every table that could hold the buyer, records that the request arrived, and alerts the operator. Three choices in it are worth stating because they are about this document’s subject matter:
- The export is never stored and never logged. It is built while the payload is in hand — which proves the assembly works, and yields a record count — and then discarded. Persisting it would create a second, denser concentration of one person’s data, needing its own erasure, its own access answer and its own retention story.
- It is never emailed. Sending the export to the merchant would mint an unbounded copy in a mailbox this app does not control and could not erase on a later
customers/redact; the app’s only wired email channel goes to the operator, not the merchant, so it would also disclose the buyer’s data to a third party. The operator alert that is sent carries no buyer content at all — only that a request exists, for which shop, and by when. - What is kept is the request, not the answer: a
CustomerDataRequestrow holding identifiers the app already stores, bounded to 30 days. Expired rows are pruned on the traffic that creates them rather than by a scheduler. The row is deleted by a latercustomers/redactfor the same person — erasure supersedes access — and byshop/redact.
The residual limitation is delivery, and it is real. The export is assembled correctly and then dropped; as of 2026-08-17 there is no merchant-facing screen that rebuilds it, so what a merchant actually receives today is the operator alert saying a request is pending. Answering it means the operator running the export by hand. The buyer’s data is no longer destroyed by asking for it, which was the serious defect; handing it back is still manual. See §8.
shop/redact
The handler deletes every shop-scoped row across all 44 tables, which is every table in the database: SurveyResponse, EmailLog, Session, BillingSchedule, ContractActivity, DunningTracker, JobQueue, Survey, TrackingSettings, CarrierAlias, PostPurchaseFlow, CheckoutDiscountRule, Appearance, Funnel, FunnelStep, FunnelEvent, RetentionReason, RetentionOffer, RetentionSession, DisputeSettings, DisputeEvent, HeatmapSettings, HeatmapPageView, HeatmapClick, Review, ReviewSettings, SplitTest, SplitEvent, NotificationSettings, Profile, Suppression, Flow, FlowStep, FlowRun, EmailTemplate, Campaign, SetupState, PublicationSettings, CampaignRecipient, EmailEvent, EmailDeliveryEvent, ChatConversation, ChatMessage, CustomerDataRequest. Nothing the app holds for that shop survives.
One nuance, because “every row” is a strong claim: EmailDeliveryEvent rows with no shop are not shop-scoped and so are not reached by a shop-scoped delete. A missing shop means the send the event referred to was already gone, so no merchant’s data is left behind — those rows are removed by a customers/redact for the address, as described above.
Two checks exist to stop that claim quietly becoming false again, and it is worth being precise about how much each one guarantees:
- A schema-derived script reads the database schema, lists every table with a
shopcolumn, and fails naming any that the deletion job does not delete. It reports 44 shop-scoped tables; 44 purged onshop/redactas of 2026-08-17. It does not run automatically — it is a command a person has to remember to run. - An equivalent schema-derived test performs the same check inside the automated test suite, so a missing table is caught there. That is the real automated guard. An older seeded test asserts only the eleven tables it explicitly creates rows in — it would pass unchanged if another table were added and forgotten, which is exactly how the 2026-07-26 gap below went unnoticed.
Neither check runs on deploy. Wiring the coverage check into the build, or adding continuous integration at all, is an open gap.
Second historical note, and a more recent one. Between the modules added on 2026-07-26 and their inclusion here on 2026-07-30, this job deleted 8 of 28 tables while this document claimed it deleted everything. Retention offers, dispute records, reviews (which hold a buyer email), heatmap rows and split-test events all survived a
shop/redact. Nothing failed visibly — the webhook returned 200 and the uninstall looked clean — which is exactly why the automated check now exists. Any install uninstalled in that window may still hold data; it is removed by re-running the job for that shop.
Earlier historical note, kept because it matters for anyone auditing an install made before 2026-07-25: until that date this job deleted the billing schedule only, and every other shop-scoped row (including survey responses containing buyer-typed free text) survived a shop/redact. Stores uninstalled before that date may still have residue in the database and should be purged by hand.
app/uninstalled
On uninstall the app deletes the shop’s Session rows and deactivates its billing schedule. Configuration and survey data survive uninstall so that a reinstall within Shopify’s 48-hour window is not destructive; shop/redact then removes it.
Manual requests
A merchant or a data subject can email the contact address at the top of this document. Requests are actioned manually against the database by the operator. Target: 30 days, consistent with GDPR Article 12(3).
8. Known gaps — stated rather than papered over
- The heatmap and split-test ingest endpoints are unauthenticated and therefore forgeable. A storefront page has no session token and a beacon call cannot carry an app-proxy signature, so there is no cryptographic proof of origin available. The consequence is bounded to polluted aggregates: there is nothing to read, nothing personal is stored, and no other part of the app can be reached. Stated rather than hidden.
- Their rate limiter is per-instance, not global. The hosting platform may run many instances, so the effective limit is higher than the configured one. It bounds accidental floods, not a determined one.
- No field-level encryption. The merchant’s Shopify access token and all other columns rely solely on Cloudflare’s at-rest encryption. Anyone with operator-level Cloudflare access can read every shop’s access token.
- Email addresses appear in application logs. The Klaviyo bridge logs the customer email on the success path and on the shop-not-configured path. Those lines land in Cloudflare’s log store, which is therefore a secondary (short-lived) store of personal data. Webhook payloads are correctly not logged; these two lines are the exception and should be reduced to a hashed or truncated identifier.
- Shopify Admin API HTTP request logging is on, so request URLs — which can carry IDs — are logged.
customers/data_requestproduces nothing a merchant can hand over yet. Until 2026-08-17 it ran the deletion path, which was worse: an access request destroyed the data instead of returning it. It now reads only and stores the request rather than the answer (§7). What is still missing is the merchant-facing screen that rebuilds the export, so fulfilling a request is an operator task performed by hand within the 30-day window.- Attribution is incomplete by design, and the incompleteness favours the customer. Only campaign sends record a recipient address on their
EmailEvent, so only campaign clicks can be tied to an order. Flow and transactional clicks record an empty address and are unattributable. This is worth stating because it means the app holds less linkage than a merchant might assume from the reporting, not more. - Single-operator model. No separation of duties, no per-record access audit trail, no formal on-call rota. An incident-response runbook exists but is internal and deliberately unpublished — it enumerates secret names and rotation procedures. The public summary is https://kddbrands.com/store-integration/security
- Tenant isolation is application-level, enforced by shop-scoped queries rather than by a database boundary. A missing shop filter in future code would be a cross-tenant leak; there is no database-level guard.
- Nothing is deleted on a schedule. The heatmap tables carry a stamped expiry and no job reads it unprompted. Purging heatmap rows requires the operator to call the bearer-gated admin endpoint by hand (§5). A merchant’s stated 30-day heatmap retention is therefore a ceiling the app intends, not one it enforces. Every table is kept for the life of the install with one exception, added 2026-08-17: a
CustomerDataRequestrow has a real 30-day bound, enforced by the next data request or redaction for that shop rather than by a scheduler — so a shop that receives no further requests still keeps an expired row until it does. - No continuous integration, and the compliance checks are opt-in. The redaction-coverage checks, the scope check and the test suite all have to be run by a person; nothing runs them on build or deploy.
shop/redactcompleteness is verified today (44/44) but nothing prevents the next table from silently breaking it before anyone looks. - Chat transcripts have no expiry and no delete control. A conversation and its messages live for the life of the install. The merchant’s inbox can close a conversation but not delete one, and no job prunes old ones. Only the two redaction webhooks remove a transcript, and a guest buyer’s checkout conversation carries neither a customer ID nor an order ID, so
customers/redactcannot match it (§7). - The checkout heatmap has never run end to end. The
read_advanced_dom_pixel_eventspermission is granted through a Partner Dashboard request. Everything downstream of the event payload is tested against Shopify’s documented shapes rather than against a real event. The module fails closed at four separate points (§2.3c), so the consequence of the untested path is that it collects nothing, not that it collects too much — but it is stated rather than implied.
9. Children
The app is a merchant tool and is not directed at children. It stores no age data and makes no attempt to identify minors.
10. Changes
Material changes to this policy will be reflected here and, for changes that expand what is collected or who receives it, notified to installed merchants before taking effect.
11. How this document is kept accurate
This policy is written from the app’s source code and is re-verified whenever the database schema changes. Automated checks read the schema directly and fail if a table exists that this document does not describe, if a table holding a buyer identifier is not reached by customers/redact, or if a table is not deleted by shop/redact. Where a check is manual rather than automatic, §8 says so.