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-07-26
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 (binding
DB, database kdd-app-integration; see
wrangler.toml:30-35). The schema is prisma/schema.prisma.
Every table below is scoped by shop (the merchant’s
*.myshopify.com domain).
2.1 Tables containing personal data
| Table | Personal data columns | Whose data | Why it is stored |
|---|---|---|---|
Session( prisma/schema.prisma:13-29) |
accessToken, userId, firstName,
lastName, email, locale,
accountOwner, collaborator,
emailVerified, scope, expires |
Merchant staff / store owner | Required OAuth session storage for @shopify/shopify-app-remix.
accessToken is a credential, not merely personal
data, and is the most sensitive value the app holds. |
SurveyResponse( prisma/schema.prisma:200-218) |
customerId (numeric Shopify customer id from the session token
sub claim), 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( prisma/schema.prisma:258-271) |
customerId, orderId, contractId,
plus template, channel, status,
reason. No name, email address or message body. |
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 see what was attempted and whether it was emitted, skipped or failed. |
ContractActivity( prisma/schema.prisma:233-247) |
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). Holds no customer identifiers and no buyer-typed text by design
(app/lib/activity/log.server.ts). |
DunningTracker( prisma/schema.prisma:273-284) |
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. |
JobQueue( prisma/schema.prisma:45-64) |
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 deadLettered on repeated failure — see
§5). |
2.2 Tables containing merchant configuration only (no personal data)
BillingSchedule, Survey, TrackingSettings,
CarrierAlias, PostPurchaseFlow,
CheckoutDiscountRule. 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/discount rules. They contain no data about
identifiable customers.
2.3 What the app deliberately does not store
- No order contents, customer names, addresses, phone numbers or email
addresses are persisted. The branded tracking page
(
app/lib/tracking/lookup.server.ts) queries the Shopify Admin API live on each lookup, compares the supplied email or postcode in memory, and returns a masked result. Nothing from that query is written to D1. - 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
(
app/services/SendPaymentMethodUpdateEmailService.ts). - No analytics/tracking pixels, no advertising identifiers, no cross-merchant profiling. Data is never combined across merchants and is never sold.
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
Session.accessToken. This is a known weakness and is recorded as such in §8. - In transit: HTTPS/TLS 1.2+ everywhere. The Worker is served only
over HTTPS and sets
Strict-Transport-Security(app/entry.server.tsx:135-138) plus a restrictiveContent-Security-Policy(app/entry.server.tsx:124-134). - Region: Cloudflare’s global network; D1 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
route is behind Shopify session-token authentication
(
authenticate.admin, configured inapp/shopify.server.ts:39-69), 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/
wrangleraccess to the D1 database and Workers Logs, and via the/api/adminendpoint gated by theKDDAPP_ADMIN_KEYbearer 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 model carries a shop
column and every read/write filters on it.
5. Retention
Summarised here; the authoritative, per-table version with the code paths is the data retention policy.
Session— deleted on app uninstall (DisableShopJob.disableShop,app/jobs/shop/DisableShopJob.ts:23-26) and again onshop/redact.SurveyResponse— kept until the merchant deletes it, until acustomers/redactwebhook matches it, or untilshop/redact. No automatic age-based expiry.JobQueue— deleted when the job runs; dead-lettered rows are kept indefinitely for inspection (prisma/schema.prisma:58-60).- 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 (Workers), the database (D1), request/application logs (Workers Logs,
enabled at wrangler.toml:27-28), and outbound operational alert
email ([[send_email]], wrangler.toml). |
Always. |
| Klaviyo | Only if the merchant’s shop domain is present in the
KLAVIYO_KEYS secret. Receives a subscription
lifecycle event containing the customer’s email address
plus event properties
(app/lib/integrations/klaviyo.server.ts:144-148). If the shop is
not in the map, nothing is sent. |
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 App Bridge/Polaris assets.
7. Deletion and data-subject requests
The app implements Shopify’s three mandatory compliance webhooks. All three are
HMAC-verified by authenticate.webhook() before any work happens, so a forged
request cannot trigger a deletion.
customers/redact and customers/data_request
app/routes/webhooks.customer_redact.ts — one handler serves both
topics (declared together in
shopify.app.omni-applications.toml). It deletes every
SurveyResponse and EmailLog row for that shop
whose customerId matches the
payload’s customer id, or whose orderId matches an entry in
orders_to_redact (webhooks.customer_redact.ts:45-60). Both the
bare numeric id and the gid:// form are matched. The webhook payload is
never logged — it is the personal data
(webhooks.customer_redact.ts:21).
Honest limitation: the same delete-everything behaviour is applied to
customers/data_request. A data request is a request to produce
data, not to destroy it; the app currently produces nothing and deletes the matching
survey and notification rows instead. Those two tables are the only buyer-keyed data the
app holds, so nothing is withheld from a requester — but a merchant who receives a
data request and expects the app to hand back the customer’s survey answers will
get an empty response and no rows. See §8.
shop/redact
app/routes/webhooks.shop_redact.ts enqueues
DeleteBillingScheduleJob
(app/jobs/shop/DeleteBillingScheduleJob.ts), which deletes
every shop-scoped row: SurveyResponse,
EmailLog, Session, BillingSchedule,
ContractActivity, DunningTracker,
JobQueue, Survey, TrackingSettings,
CarrierAlias, PostPurchaseFlow,
CheckoutDiscountRule — twelve tables. Nothing the app holds for that
shop survives.
Historical note, kept because it matters for anyone auditing an install made before 2026-07-25: until that date this job deleted
BillingScheduleonly, and every other shop-scoped row (including survey responses containing buyer-typed free text) survived ashop/redact. Stores uninstalled before that date may still have residue in D1 and should be purged by hand.
app/uninstalled
app/routes/webhooks.app.uninstalled.tsx enqueues
DisableShopJob, which deletes the shop’s Session rows and
deactivates its BillingSchedule
(app/jobs/shop/DisableShopJob.ts:23-26). 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 privacy@kddbrands.com. Requests are actioned manually against D1 by the operator. Target: 30 days, consistent with GDPR Article 12(3).
8. Known gaps — stated rather than papered over
- No field-level encryption.
Session.accessTokenand 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 at
infolevel on both the success path (app/lib/integrations/klaviyo.server.ts:174) and the no-key path (:122-123). Those lines land in Cloudflare Workers Logs, which is therefore a secondary (short-lived) store of personal data. Webhook payloads are correctly not logged, and the no-profile-email path (:112-117) logs only event properties; these two lines are the exception and should be reduced to a hashed or truncated identifier. - Shopify Admin API HTTP request logging is on
(
httpRequests: true,app/shopify.server.ts:56), so request URLs — which can carry ids — are logged. customers/data_requestdeletes rather than produces (§7).- Single-operator model. No separation of duties, no per-record access audit trail, no formal on-call rota. See our security and incident commitments for what does exist.
- Tenant isolation is application-level, enforced by
shop-scoped queries rather than by a database boundary. A missingwhere: {shop}clause in future code would be a cross-tenant leak; there is no database-level guard.
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.
Last reviewed 2026-07-26 · KDD Brands LTD (company 16411950) · privacy@kddbrands.com