Ferry — Data Retention Policy
Effective date: 3 August 2026 Owner: Prendit EOOD (Прендит ЕООД) · privacy@ferrycommerce.com
Principle
We keep personal data only as long as necessary for the purpose it was collected (creating and tracking a shipment and, where a merchant opts in, preventing COD fraud) and to meet legal/accounting obligations. Data is minimised on collection and deleted — or reduced to pseudonymised/aggregated form — once no longer needed. (A keyed hash is pseudonymisation, not anonymisation: pseudonymous data remains personal data throughout this policy.)
Retention schedule
| Data category | Retention period | Trigger to delete |
|---|---|---|
| Shipment personal data (recipient name, address/office, phone, email) | 90 days after the shipment reaches a terminal tracking state (delivered/returned/cancelled), clocked from the shipment’s LAST poll — for a tracked return-to-sender course, that is the physical hand-back to the sender (or the 14-day abandon), not the first return signal | Scheduled purge job |
| Label PDFs (R2, may contain name/address) | Same as shipment personal data | Scheduled purge job deletes the R2 object |
Stored delivery address (orders.delivery_address_json — written when the buyer picks “до адрес” in the post-purchase picker, or to preserve the original Shopify shipping address before Ferry rewrites it to an office / corrected form) |
90 days after orders.created_at |
Scheduled retention purge NULLs the column; also erased per-order by customers/redact and hard-deleted (whole row) by shop/redact |
Address-normalization input/result (orders.address_norm_input_json / normalized_address_json — the delivery address as the buyer wrote it and the corrected form, stored when a courier rejects an address; the transient AI call is described in the Privacy Policy §2.1) |
90 days after orders.created_at |
Same purge statement as the stored delivery address; erased by customers/redact; row-deleted by shop/redact |
Courier rejection text (shipments.last_error — the courier’s verbatim error message for a failed waybill; may quote address fragments the courier echoed back) |
Until the next successful waybill for that order; scrubbed by the 90-day purge once the shipment reaches a terminal state. An age-based cleanup for abandoned failed shipments (which never reach a terminal state) is being added; until then those rows are cleared on erasure requests and uninstall | Cleared on a later successful mint; scrubbed by the retention purge (terminal shipments) and by customers/redact; row-deleted by shop/redact |
| Order/shipment metadata without direct identifiers (order id, COD amount, courier, status, waybill/tracking numbers) | May be retained longer for analytics/reconciliation — pseudonymised (no name/address/phone/email, but linkable to an order via its identifiers) | Hard-deleted by shop/redact |
| Merchant account & courier credentials | For the lifetime of the installation | On app uninstall / shop/redact |
Buyer risk-screening signal (orders.risk_level, risk_report_count, risk_reasons_json, risk_provider, risk_checked_at, risk_hold_status, risk_reported_at, and the per-source breakdown risk_sources_json) — pseudonymous personal data relating to the buyer (linked via the order; carries no direct identifiers — the phone/email used for the check is never stored) |
90 days from the screening (the order’s creation time when a partial screen left it unset) | A scheduled purge clears the level, count, reasons, per-source breakdown, provider, check time and hold state, and marks the order so it is never re-screened. The timestamp recording that a report was published is deliberately kept — it is the guard against a second public report about the same buyer. Also hard-deleted with the order row by shop/redact, and erasable on a verified buyer-direct request (§8 of the Privacy Policy) |
Buyer-history key (orders.buyer_phone_hash) — a keyed hash (HMAC) of the buyer phone under Ferry’s server secret, set at ingest so the per-merchant buyer-history risk source can match a repeat buyer without storing any plaintext phone; pseudonymized, not reversible without the secret. The hash key is global (one server secret), so the same phone yields the same digest across shops — but every buyer-history query is strictly tenant-scoped; the only cross-tenant readers are the opt-in network signal below and the TTL-bounded verdict cache |
90 days after orders.created_at |
Scheduled retention purge NULLs the column; also erased per-order by customers/redact and hard-deleted (whole row) by shop/redact |
Shared returned-parcel events (buyer_reputation_events) — one row per returned COD parcel: HMAC-SHA256 hash of the normalized buyer phone keyed with Ferry’s server secret (pseudonymized, not reversible without the secret), shipment reference, contributing shop, kind='returned' marker, and timestamp; no names, addresses, emails, order amounts, or plaintext phones |
365 days, auto-purged in lockstep with NETWORK_REPUTATION_RETENTION_MOD = '-365 days'. |
customers/redact deletes all rows for the buyer’s hash across shops when Shopify supplies the buyer’s phone in the payload (the rows hold only the keyed hash, so without it they cannot be located and expire on the 365-day limit); shop/redact deletes the shop’s rows; merchant opt-out immediately deletes the shop’s contributed rows |
Merchant’s own risk-provider API key (risk_provider_credentials.api_key_enc, encrypted at rest; when configured, overrides Ferry’s platform nekorekten token for that shop’s checks, and is always the account used to publish signals) |
For the lifetime of the installation | Deleted on app uninstall / shop/redact |
Cross-tenant risk-verdict cache (Cloudflare KV; keyed by a keyed hash (HMAC) of the buyer phone under Ferry’s server secret, value = the derived public risk signal) — pseudonymized via a keyed hash (HMAC), not reversible without Ferry’s server secret; TTL-bounded (7 days) and best-effort purged on customers/redact |
7 days (TTL) | Auto-expires; best-effort purged on customers/redact when Shopify supplies the phone; not shop-scoped, so it is not part of the shop/redact or 90-day purge flows |
COD settlement ledger (cod_settlements — courier payout/document refs, tracking number, gross/fee/net amounts, currency, remittance date) — the courier→merchant money layer; contains no buyer PII |
Lifetime of the installation (financial reconciliation record) | Hard-deleted by shop/redact; not touched by the 90-day PII purge |
Support tickets (support_tickets + support_ticket_messages — merchant-authored ticket text, an AI-generated summary, optional attachments in R2 under tickets/{shopId}/) — merchant↔Ferry business correspondence; contains no systematic buyer PII, though merchant free text may occasionally reference order/customer details |
Lifetime of the installation (kept as business correspondence) | Hard-deleted (D1 rows + R2 attachments) by shop/redact; not part of the 90-day shipment-PII purge |
Office-pick short links (pick_short_links — random per-order slugs behind the reminder SMS link; no personal data) |
30 days | Daily expiry purge; hard-deleted by shop/redact |
Operational/tenant records without buyer PII (order_returns return-lifecycle mirror, pickup_claims courier-pickup ledger, reminder/archive/reconcile timestamp latches on orders) |
Lifetime of the installation (operational bookkeeping) | Hard-deleted shop-scoped by shop/redact; not part of the 90-day PII purge (they carry no personal data) |
Operational logs in D1 (webhook_log dedupe keys, job_log status rows — identifiers and statuses only, no personal data) |
Operational bookkeeping; retained until routine cleanup — their deletion on shop redaction is being added | Not part of the PII purge |
GDPR request audit log (gdpr_requests — which redaction/data requests were received and processed: shop id, customer/order identifiers, timestamps; no name/address/phone/email) |
Retained after redaction as compliance evidence (accountability, Art. 5(2)) — a maximum period is being defined | Deliberately survives shop/redact (it documents that the redaction happened) |
Billing/usage records (usage_charges — per-waybill usage-fee ledger: shop id, shipment id, amounts, Shopify usage-record ids; shop-level configuration rows such as shop_settings / shipping_mappings carry no personal data) |
Financial/accounting records — retained per statutory accounting periods; configuration rows are housekeeping | Not part of the PII purge; financial records may lawfully survive shop/redact; configuration rows’ deletion on shop redaction is being added |
| Cached courier nomenclature (offices/cities — not personal data) | TTL-based cache (KV) | Cache expiry |
| Operational logs (Cloudflare Workers logs/observability) | 30 days or less (platform default) | Automatic log rotation |
Deletion on request (Shopify mandatory webhooks)
Ferry honours Shopify’s GDPR webhooks:
customers/data_request— compile an inventory of what Ferry holds for that customer (which shipments carry a stored label / tracking payload / address JSON, per leg) for the merchant. Because Ferry persists almost no direct identifiers, the stored artifacts themselves are limited to label PDFs, tracking payloads and the address JSON, retrievable per-order on request. A packaged export of those artifacts, including the derived risk fields, is being added.customers/redact— delete that customer’s personal data (shipment records for BOTH the outbound and return legs, label PDFs, address JSON, phone hash) within the timeframe Shopify requires (currently 30 days of receipt).shop/redact— sent by Shopify ~48 hours after uninstall: delete the shop’s personal data (shipments, credentials, tokens, labels, support tickets), subject to the documented audit / financial-record exceptions in the schedule above.
Implementation status: the scheduled purge job and the three webhook handlers are implemented. The webhooks verify the HMAC then enqueue; the work runs in the Queue consumer. The purge sweep runs on the frequent (every-5-minute) cron tick, bounded per run, and scrubs shipment PII 90 days after a terminal state — the clock is anchored at the shipment’s last poll, not the moment it first turned terminal. For most shipments the two coincide (the poll that turns a shipment terminal is also its last one). The one case they diverge: a return-to-sender course turns
returned(terminal) on the courier’s FIRST return signal, but Ferry keeps polling it every ~6h until the parcel is physically back with the sender — each of those polls re-stampslast_polled_at, so the 90-day clock only starts at the real hand-back (or, for a course the courier stops reporting on, the 14-day abandon cutoff), never at the first return signal.By data-minimisation, Ferry does not persist recipient name/address/phone/email in its database from the Shopify order itself — those are read live from Shopify only when a waybill is created and handed to the courier (the same applies to the office-pick reminder e-mails/SMS: the buyer’s e-mail/phone are read live per send and not persisted in Ferry’s database). The shipment personal data that lives at rest is therefore the R2 label PDFs (name/address on the label), the courier tracking payloads (
tracking_events), the stored delivery address (orders.delivery_address_json— the buyer’s “до адрес” pick, or the original Shopify shipping address preserved before Ferry rewrites it to an office / corrected form), the address-normalization input/result JSON, the courier rejection text (shipments.last_error), and the pseudonymizedbuyer_phone_hash(see below). The purge and redact handlers delete all of these, keeping only pseudonymised order/shipment metadata (no direct identifiers; order/tracking numbers remain linkable).Return waybills are covered in both directions: an order can carry an outbound and a return shipment (the buyer is the sender on the return leg), and the purge,
customers/redactandcustomers/data_requesthandlers iterate both legs — a return waybill’s label PDF and tracking payloads are treated as buyer PII exactly like the outbound ones.Buyer risk screening: for cash-on-delivery (COD) orders, Ferry checks the buyer’s phone/email against nekorekten.com by default, using Ferry’s own platform nekorekten token — screening targets orders whose payment is still outstanding at intake (the COD pattern); orders already paid/refunded/voided are never screened (data minimisation). A merchant may connect their own nekorekten API key, which then overrides Ferry’s platform token for that shop’s checks. Either way, the phone/email sent for the check is not written to Ferry’s database — only the derived risk signal (level, report count, non-PII reason codes, provider, timestamp, hold status) is stored on the order, plus the
risk_reported_attimestamp — set when a risk signal is published (a buyer reported), whether the merchant publishes it manually or (in future) Ferry publishes it via the opt-in auto-report for an unclaimed COD parcel (a setting that is not currently active) — which is itself a non-PII fact about the order, not the buyer. Publishing (manual or automatic) always uses the merchant’s own nekorekten account; Ferry never publishes under its platform token, and the merchant is the controller of that public claim. The auto-report opt-in flag itself (shop_settings.auto_report_returned_enabled) is a non-PII shop-level setting, off by default, kept for the lifetime of the installation and not part of the 90-day purge. Ferry also maintains a short-lived cross-tenant verdict cache in Cloudflare KV, keyed by a keyed hash (HMAC) of the phone under Ferry’s server secret and capped at a 7-day TTL — the key is pseudonymized via a keyed hash (HMAC), not reversible without Ferry’s server secret, and maps only to the public risk signal; it is best-effort purged oncustomers/redact(when Shopify supplies the phone) and otherwise auto-expires on its own. The stored order-level signal is pseudonymous personal data (linked to the buyer via the order), so it carries its own defined maximum period: 90 days from the screening, enforced by the same scheduled purge that scrubs shipment PII and the stored delivery address. A purged order is marked so that it is never re-screened — otherwise a later order update would simply re-collect what was just erased. Correcting or deleting a source event also propagates to the derived signals stamped on other merchants’ orders: the affected line is stripped and the composite re-derived from the sources that remain (Art. 19). The risk columns are additionally hard-deleted when the whole order row is removed byshop/redact, and erasable on a verified buyer-direct request. The merchant’s own nekorekten API key (risk_provider_credentials.api_key_enc) is a separate, encrypted-at-rest secret, deleted onshop/redactalongside the other courier credentials.Multi-source risk aggregation + buyer-history: the check above is now one of several risk sources combined into a single composite verdict (the worst source’s level wins); the per-source breakdown is stored on the order as
risk_sources_json(derived, non-PII — bare counts + short reason strings). Alongside the external nekorekten source, Ferry derives a per-merchant buyer-history signal from the merchant’s own delivery outcomes: if the same buyer already had a parcel returned at this merchant, the new order is flagged (never auto-held — that signal caps at “flag”). To match a repeat buyer without storing any plaintext phone, each order carriesbuyer_phone_hash— a keyed hash (HMAC) of the buyer’s phone under Ferry’s server secret, computed at ingest; it is pseudonymized (not reversible without the secret). The hash key is a single server secret, so the digest is stable across shops, but the buyer-history queries are strictly tenant-scoped (this signal uses only the merchant’s own data, so it is not a sub-processor disclosure). The separate, opt-inbuyer_reputation_eventsnetwork store is the only exception: it contributes pseudonymized returned-COD events and exposes only a derived count from other Ferry stores to participating merchants, as described in the schedule above.buyer_phone_hashis treated as pseudonymized personal data: it is scrubbed oncustomers/redact, removed with the order onshop/redact, and — unlike the derived risk columns — included in the 90-day retention purge.COD settlement ledger: the
cod_settlementstable records the courier→merchant money layer — courier payout/document refs, the tracking number, and gross/fee/net amounts pulled from the courier settlement reports (Speedy/payments, EcontPaymentReport). It carries no buyer PII (no name/address/phone/email), so it is kept for the lifetime of the installation as a financial reconciliation record and is not part of the 90-day PII purge. It is hard-deleted, shop-scoped, byshop/redactalongside the other shop rows.Fiscal receipt (касов бон): to have the courier issue a fiscal receipt on a COD delivery, Ferry sends itemized goods data (product name, quantity, unit price) to the courier alongside the waybill. This is read live from the Shopify order at waybill-creation time and is not persisted in Ferry’s database at rest — the only artifact retained is the label PDF (already covered by the 90-day shipment-personal-data purge above). Product names/prices are low sensitivity (not personal data), and no buyer PII is added to the flow beyond what waybill creation already sends.
AI address normalization: when a courier rejects a delivery address, Ferry may send the address fields (city/street/number/postcode/quarter/note — never the buyer’s name, phone or e-mail) plus the courier’s error message to the Anthropic API to propose a corrected form (see the DPA sub-processor table). The transit is per-request; what is retained at rest is the input/result JSON on the order (
address_norm_input_json/normalized_address_json), covered by the 90-day purge and the redact flows per the schedule above.Office-pick reminders: the reminder e-mail (via Cloudflare Email) and the opt-in reminder SMS (via SMSAPI) read the buyer’s e-mail/phone live from the order per send; Ferry stores only non-PII reminder counters/timestamps and the random short-link slug (
pick_short_links, 30-day expiry).Support tickets: the in-admin support chat stores each merchant’s ticket text, the thread of messages (merchant/AI/owner), an AI-generated summary, and any attachments the merchant uploads (screenshots/PDFs, capped at 5MB each) — D1 rows in
support_tickets/support_ticket_messagesplus R2 objects under the shop’s owntickets/{shopId}/prefix. This is merchant-authored business correspondence (support/bug reports), not systematic buyer PII — though a merchant’s free-text description may incidentally mention an order number or a customer’s name. Because it documents the support relationship for the life of the installation, it is excluded from the 90-day shipment-PII purge and kept until the shop uninstalls.shop/redacthard-deletes it in full: every ticket + message row for the shop, and every R2 object under itstickets/{shopId}/prefix.Return/refusal handling: the two
orderscolumns that drive the refused-COD cancel flow —refused_observed_at(the auto-sweep’s 24h-clock stamp) andrefused_cancelled_at(the “reflected in Shopify” latch) — are timestamps only, no buyer PII. Like the COD/archive latches (cod_paid_at/archived_at), they are not part of the 90-day shipment-PII purge and carry no personal data; they are hard-deleted with the order row onshop/redact. The cancel action itself only mutates the Shopify order (no new data at rest).
How deletion works
- D1: rows scoped by
shop_id(and customer/order id) are hard-deleted — subject to the documented exceptions in the schedule (gdpr_requestsaudit, financial records, and thewebhook_log/job_logoperational rows until their redaction deletion lands). - R2: label objects are deleted by key.
- KV: credential/session/cache entries are deleted or allowed to expire.
- Queues: pipeline messages (which may transiently carry an order payload incl. buyer phone/email) exist only until delivery or, for undeliverable messages, until the dead-letter queue drains — bounded to days; the dead-letter record kept in D1 is a non-PII status row.
- Operational logs rotate per the platform retention in the schedule above. Buyer contact data is not written to logs in normal operation; one provider-misconfiguration fallback that logs the destination contact of an office-pick reminder is being removed.
- Backups managed by Cloudflare age out per the platform’s retention; we do not keep separate long-lived backups containing personal data.
Review
This policy is reviewed at least annually and whenever processing changes.