KDD Brands → Store Integration → Data retention
Data Retention Policy — Store Integration
This policy states the retention period actually implemented in code, and names the code path that enforces it. Where retention is “indefinite until an event”, it says so rather than inventing a period the app does not enforce.
- Scope
- Every persistent store the app controls — the Cloudflare D1 database and Cloudflare Workers Logs.
- Companion docs
- Privacy policy (what is stored and why) · Security & incidents
- Last reviewed
- 2026-08-17
1. Retention schedule
The app’s database contains 44 tables. Every one of them carries a shop column, so every one is shop-scoped and every one appears below. Until 2026-07-30 this section said “twelve” and listed twelve; sixteen tables — including Review, which holds a buyer’s email address — had no retention row at all.
On 2026-08-17 four more rows were added — ChatConversation and ChatMessage for the new in-checkout chat, CustomerDataRequest for the rebuilt customers/data_request answer, and one for EmailDeliveryEvent, which had shipped on 2026-08-05 and was described in the privacy policy but had no retention row here at all. That omission was found by the automated check described in §5, which is exactly the failure the check exists to catch.
Nothing in this app is deleted on a schedule. The app’s hourly scheduled task dispatches recurring subscription billing and the delayed-job sweep, and nothing else. Almost every “retention period” below is therefore an event, not a clock. Read §2 for the three events.
One exception, added 2026-08-17: CustomerDataRequest has a real age bound of 30 days, and it is enforced — but by the traffic that creates such rows (every customers/data_request and every customers/redact prunes the shop’s expired ones), not by a scheduler. A shop that receives no further requests keeps an expired row until it does, or until shop/redact. The heatmap tables carry an expiry stamp that nothing reads unprompted; that is a different and weaker thing, and it is described in its own row below and in §4.
| Data | Store | Retention |
|---|---|---|
Session — merchant OAuth session, including the Shopify access token and staff name/email | Database | Until the app is uninstalled, then deleted. Also deleted on shop/redact. Rows carry an expiry for online tokens, honoured by the session storage library. |
SurveyResponse — customerId, orderId, orderName, choice, otherText (buyer free text) | Database | Indefinite while the shop is installed. Deleted on customers/redact (matching customer or order), on shop/redact, or when the merchant deletes the survey from the admin. No age-based expiry is implemented. |
EmailLog — sent-notification history: customerId, orderId, contractId, template, channel, status, reason. No name, email address or message body. | Database | Indefinite while the shop is installed. Deleted on customers/redact (matching customer or order) and on shop/redact. No age-based expiry is implemented. |
ContractActivity — subscription timeline: contractId, type, summary, detailJson, actor. Pseudonymous; holds no customer or order identifier by design. | Database | Indefinite while installed; deleted on shop/redact only. Deliberately out of scope for customers/redact, which matches on customer and order IDs this table does not carry. |
DunningTracker — failed-billing-cycle markers (pseudonymous) | Database | Indefinite while installed; deleted on shop/redact. Rows are marked complete rather than deleted once dunning ends, because completion is what stops a retry from firing again. |
JobQueue — delayed job rows; the serialised parameters may contain shop, contract or order IDs | Database | Deleted when the job runs (normal case: minutes to hours). Rows that exhaust their retry budget are kept indefinitely, dead-lettered, for inspection and manual resurrection. Deleted on shop/redact. |
BillingSchedule — per-shop billing hour/timezone | Database | Deactivated on uninstall, deleted on shop/redact. |
Survey, TrackingSettings, CarrierAlias, PostPurchaseFlow, CheckoutDiscountRule, Appearance, Funnel, FunnelStep, RetentionReason, RetentionOffer, DisputeSettings, HeatmapSettings, ReviewSettings, SplitTest, NotificationSettings, Flow, FlowStep, EmailTemplate, Campaign, SetupState, PublicationSettings — merchant configuration across every module (surveys, tracking and carrier aliases, post-purchase offers, checkout discount rules, appearance tokens, upsell funnels and their steps, cancellation reasons and save offers, dispute automation settings, heatmap settings — including the separate checkout switch and the ID of the shop’s activated web pixel, which is bookkeeping of a Shopify-side record rather than a merchant preference — review settings, split-test definitions, marketing automations and their steps, the merchant’s own wording for each message, whether the merchant has dismissed the setup checklist, and which sales channels newly added plan products should be published to). No personal data — the email templates store the text a merchant typed, not anything about a customer. | Database | Life of the install. Survives uninstall (so a reinstall inside Shopify’s 48-hour window is not destructive), then deleted on shop/redact. The merchant can delete any record at any time from the app admin. |
Review — product reviews: email (a buyer email address, never shown on the storefront), authorName (shown), orderId, plus rating, title and body (buyer free text) | Database | Indefinite while the shop is installed. On customers/redact the row is redacted, not deleted: email and orderId are cleared and authorName becomes “Anonymous”, while the rating and text remain (a published review is a statement about a product — see privacy policy §7). Deleted outright by shop/redact, or by the merchant from the admin. No age-based expiry. |
Profile — the marketing audience: email (the identity), customerId once there is one, firstName/lastName, a free-form properties blob, and the consent trail. Personal data in the strongest sense. | Database | Indefinite while installed. On customers/redact matching a customer ID the row is deleted outright — unlike the analytics tables below there is no merchant statistic computed from it, so nothing is preserved by erasing in place. Deleted on shop/redact. Consent defaults to never; a profile is not marketable until someone opts in. |
CampaignRecipient — one person’s slot in one campaign: email, status, A/B variant, and the message ID its opens and clicks attach to | Database | Indefinite while installed — it is the record of who a campaign reached. On customers/redact the row is deleted: a pending row is a scheduled send, and the uniqueness constraint on (campaign, email) makes an empty-string sentinel collide for anyone erased from two campaigns. A completed campaign’s sent count drops by one, which is the right trade against holding the address. Deleted on shop/redact. |
EmailEvent — one open or click: the message, the address, the kind, the clicked URL, and any attributed order and revenue. No IP address, no user agent, no device fingerprint, no location — those turn open tracking into surveillance and none of them answer the merchant’s question | Database | Indefinite while installed. On customers/redact the row is erased in place (email cleared, order ID nulled) and kept, because it is what the merchant’s open and click rates are computed from — deleting would rewrite their historical campaign numbers as a side effect of an unrelated person’s request. Tracking is OFF unless the merchant switches it on, per kind. Deleted on shop/redact. |
FlowRun — one person’s progress through one automation: email, which step they are on, when the next step is due, and a JSON snapshot of the trigger context | Database | Completed and exited runs are retained so a merchant can see who went through what — there is no automatic deletion on completion. On customers/redact matching the payload’s email the run is deleted outright, because it is a schedule of FUTURE sends and leaving it would keep an erased person in a nurture sequence. Deleted on shop/redact. |
EmailDeliveryEvent — what a receiving mail system said back about a message the app sent: email, the message ID, the event type (bounced/complained/delivered/deferred/failed/rejected), a hard/soft classification and the server’s diagnostic code. No message content, no subject, no name, no IP address. The shop is optional — a bounce notification arrives with no merchant identity and is matched back to the send afterwards | Database | Indefinite while installed. On customers/redact matching the payload’s email the rows are deleted outright, including rows with no shop — blanking the address would not anonymise them but corrupt them, because the soft-bounce window groups by address. Rows carrying a shop are deleted on shop/redact; rows with no shop are not, since a shop-scoped delete cannot match them — they are removed only by a customers/redact for that address. The Suppression row an event produced survives either way (below), so the protection outlives the erasure. |
Suppression — addresses this shop must not email, and why (unsubscribed, bounced, complained, manual). Holds an email address and a reason; no name, no order history, no behaviour. | Database | Indefinite, and DELIBERATELY SURVIVES customers/redact. Deleting it would make the app forget that the person unsubscribed or hard-bounced, so the next order or import would put them straight back on the list — a worse outcome for the same person than retaining one address. GDPR Art. 17(3) and ICO direct-marketing guidance both treat the minimum data needed to honour an objection as lawful to retain; a suppression list is the textbook case. Deleted on shop/redact, when the shop itself is gone. A complained entry cannot be cleared from the admin at all. |
RetentionSession — one cancellation flow: contractId, customerId, reason and which save offer was accepted | Database | Indefinite while installed. On customers/redact matching a customer ID, customerId is set to NULL and the row is kept — it is what the merchant’s save rate is computed from. Deleted on shop/redact. |
DisputeEvent — one chargeback: disputeId, orderId, orderName, customerId, status, amount, and which contracts the app paused | Database | Indefinite while installed. On customers/redact matching a customer or order ID, customerId, orderId and orderName are set to NULL and the row is kept. Deleted on shop/redact. |
SplitEvent — one split-test view or conversion: random per-browser visitor ID, variant, and on a conversion a real Shopify order ID and value | Database | Indefinite while installed. On customers/redact matching an order ID, the order ID is reset to the empty-string sentinel (not NULL — the database treats multiple nulls as distinct in the dedupe constraint) and the row is kept. Deleted on shop/redact. No age-based expiry. |
FunnelEvent — one post-purchase offer impression/accept/decline: referenceId from the verified post-purchase token, offered variant, amount | Database | Indefinite while installed. On customers/redact matching an order ID, referenceId is overwritten with 'redacted' (the column cannot be null) and the row is kept. Deleted on shop/redact. |
HeatmapPageView, HeatmapClick — anonymous on-page behaviour. No customer ID, order ID, email, IP, user agent or referrer; IDs are random per-visit/per-page-view values. Since 2026-08-17 these tables also receive checkout rows from the web pixel app extension, which add no column: the path is one of six fixed step names, never the checkout URL or its token | Database | Each row is stamped at write time with an expiry of its creation date plus the shop’s retention setting (default 30, maximum 90 days) — storefront and checkout rows alike. Nothing acts on that stamp automatically. The purge is reachable only through the operator’s bearer-gated admin endpoint — i.e. only when the operator runs it by hand. Rows therefore persist past their stamped expiry until an operator purges or a shop/redact arrives. Out of scope for customers/redact: there is no identifier to match. |
CustomerDataRequest — the queue of unanswered customers/data_request webhooks: Shopify’s request ID, the buyer as the payload identified them (customerId and/or email — both optional, because a guest checkout has an email and no customer ID), the requested order IDs as a JSON string, and a status. The assembled export is NOT stored here or anywhere — the row is the request, not the answer | Database | 30 days from the request, stamped at write time. Enforced by a prune that runs on every customers/data_request and every customers/redact for that shop — traffic-driven, not scheduled, so a quiet shop keeps an expired row until its next request. Also deleted by a customers/redact matching the same person (erasure supersedes access) and by shop/redact. |
ChatConversation — one in-checkout support conversation: status, surface (checkout/thank_you), optional customerId (from the verified session-token sub claim), optional orderId/orderName, and a buyerName only if the buyer typed one. No email address, no phone number, no postal address, no IP address | Database | Indefinite while installed. There is no age-based expiry, no purge job and no delete control — the merchant inbox can close a conversation, which changes its status and deletes nothing. Deleted outright on customers/redact matching a customer ID or an order ID, and on shop/redact. A guest conversation opened at checkout has neither identifier, so a redaction cannot match it — see §4. |
ChatMessage — one message in a transcript: body (free text typed by the buyer or by merchant staff, capped at 2,000 characters), the role (buyer/merchant/system), and the conversation it belongs to. Never written to a log line | Database | Indefinite while installed, on the same terms as the conversation above. On customers/redact 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. Deleted on shop/redact. |
| Application logs — including the customer email lines noted in privacy policy §8 | Cloudflare | Cloudflare’s own log retention (days, not months — set by the hosting plan, not by this app). Not independently exported or archived by the app. |
Ops alert emails — ALERT: notifications to the operator | Operator mailbox | Contain shop domain, job name and error text; no customer identifiers by design. Retained per the operator’s mailbox policy. |
| Data sent to Klaviyo (opt-in shops only) | Klaviyo | Out of this app’s control once sent. Governed by the merchant’s own Klaviyo account and retention settings. |
2. The three deletion triggers, precisely
app/uninstalled→ deletes the shop’sSessionrows and deactivates its billing schedule. Nothing else is removed. Deliberate: Shopify sendsshop/redact48 hours later, and a merchant who reinstalls in the meantime keeps their configuration.customers/redact→ reaches fourteen tables, in two different ways. That is not a maintained list: an automated check derives from the database schema which tables hold a buyer identifier and verifies each is reached, and reports fourteen holding one, fourteen reached as of 2026-08-17.
customers/data_request no longer runs this path. Until 2026-08-17 the handler served both topics without branching, so an access request destroyed the data instead of returning it. The topics are now answered separately: a data request reads, records a CustomerDataRequest row and deletes nothing. - deleted: matching SurveyResponse and EmailLog rows; the buyer’s ChatConversation rows and every ChatMessage inside them; any pending CustomerDataRequest for that person; Profile, FlowRun, CampaignRecipient and EmailDeliveryEvent matched by email; - identifier erased, row kept: RetentionSession.customerId, DisputeEvent.customerId/orderId/orderName, SplitEvent.orderId, FunnelEvent.referenceId, EmailEvent's email and order ID, and Review's email, order ID and author name. These rows are what save rates, dispute history, split-test results, open and click rates and the public review corpus are built from; deleting them would rewrite a merchant’s history as a side effect of one customer’s request. Erasure covers the personal data, not the fact that the event happened.
Until 2026-07-30 this section claimed SurveyResponse and EmailLog were “the only tables holding buyer-keyed data”. They were not — five more tables held a Shopify customer ID, order ID or email, and the app holds Shopify Admin API tokens, so those IDs resolve to a named person. The handler now covers them; the claim is deleted rather than reworded.
Still not reached, and this is the honest residue: ContractActivity and DunningTracker (keyed by contract only, which Shopify can link back to a buyer), dead-lettered JobQueue parameters (IDs live inside a JSON string, not a column), and the heatmap tables (nothing identifying to match, on the checkout surface as much as on the storefront). All are cleared by shop/redact.
shop/redact→ deletes every shop-scoped row in all 44 tables. This is the complete erasure path, and it is the only one that reaches everything — with one stated exception:EmailDeliveryEventrows with no shop are not shop-scoped and a shop-scoped delete cannot match them. A missing shop means the send they referred to was already gone, so no merchant’s data is left behind; those rows are removed by acustomers/redactfor the address.
All three handlers verify Shopify’s HMAC signature before touching data, so deletion cannot be triggered by an unauthenticated caller.
3. Backups
The app performs no application-level backups and keeps no separate copy of the database. Cloudflare’s own point-in-time recovery applies at the platform level; a restore would reintroduce deleted rows, so any restore must be followed by re-running the outstanding redactions. This is a manual step and is listed in the internal incident-response runbook.
4. Gaps in this policy
Stated so a reviewer does not have to discover them:
- Almost no age-based expiry, and nothing runs on a schedule to delete.
CustomerDataRequestis the sole exception and it is traffic-swept rather than scheduled (§1). Survey responses, notification logs, reviews, split-test events and dispute records are all kept for the life of the install. A merchant who wants a 12- or 24-month ceiling has to delete the records. A scheduled pruning job is the obvious fix and is not built. - The heatmap retention setting is not enforced by anything automatic. A merchant setting “30 days” in the admin gets an expiry stamp on each row and nothing that reads it. Only a manual operator purge, or
shop/redact, actually removes those rows. This is the single largest gap between what the app’s UI implies and what it does; the fix is a scheduled purge, and it is not built. It applies equally to the checkout rows written by the web pixel extension since 2026-08-17. ContractActivity,DunningTrackerand dead-letteredJobQueuerows outlive a customer redaction. None carries a customer ID, so a customer-scoped redaction cannot match them — but all three carry a contract ID (and a queued job may carry an order ID inside its parameters) that Shopify can resolve to the buyer. Defensible, but it is residual data, not an empty set.customers/redactmatching is asymmetric.RetentionSessionis matched only on customer ID (it has no order ID column);SplitEventandFunnelEventonly on order ID (they have no customer ID). A payload carrying a customer ID but an emptyorders_to_redactleaves that buyer’s split-test and funnel rows intact, and vice versa.- Chat transcripts have no ceiling of any kind. No expiry, no purge job, and no delete control in the merchant inbox — closing a conversation only changes its status. A transcript is buyer-typed free text, so this is the module where “kept for the life of the install” costs the most. The same scheduled pruning job that would fix the heatmap gap would fix this one.
- A chat conversation is matched on customer ID or order ID, never on email, because it has no email column — the module never asks a buyer for an address. A buyer who opened a chat at checkout while signed out has neither identifier on the row (at checkout there is no order yet), so a
customers/redactfor that person cannot reach their transcript. Removed only byshop/redactor by hand. Reviewis matched by email and only by email. A reviewer who left no email, or whose Shopify account email differs from the one on the review, is not reached bycustomers/redactat all.- Dead-lettered queued jobs are kept forever by design; they can carry order and contract IDs. They are removed only by
shop/redactor by hand. - Log retention is Cloudflare’s, not ours, and the app does not currently scrub the email addresses the Klaviyo bridge writes into those logs (privacy policy §8).
5. Review
This policy is reviewed whenever the database schema gains a new table. Any new shop-scoped table must be added to the shop/redact deletion job and to the table in §1, or the app silently stops being redact-complete.
Three automated checks exist, and all three read the schema directly rather than a maintained list:
- one verifies that every shop-scoped table is deleted by
shop/redact— as of 2026-08-17 it reports 44 shop-scoped tables and 44 purged; - one verifies that every table holding a buyer identifier is reached by
customers/redact— as of 2026-08-17, fourteen holding one and fourteen reached; - one guards this document: it fails if a shop-scoped table has no retention row here or no description in the privacy policy, and it fails if the table count stated in §1 stops matching the schema. That check exists because the stale “twelve” above survived sixteen new tables.
The same shop/redact completeness check also runs inside the app’s automated test suite, so an omission fails the tests whether or not anyone remembers to run the script.
None of them runs automatically on build or deploy, and this repository has no continuous integration. Somebody has to run them by hand. Wiring at least the first into the build is an open gap.
Last reviewed 2026-08-17 · KDD Brands LTD (company 16411950) · privacy@kddbrands.com