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, reading a conversation to draft the order behind it, and — where a merchant or seller 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.)
Prendit EOOD operates two products (Privacy Policy §1) and they delete on different mechanisms, so they have separate schedules: Ferry (Shopify) below, Ferry Social in its own section further down.
Retention schedule — Ferry (Shopify)
| 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 |
GDPR export archive (R2 gdpr/<shop>/<request-id>.zip — the packaged answer to a customers/data_request: the label PDFs, tracking events, stored address JSON and risk signal of the requested orders, i.e. a frozen copy of personal data already covered by the rows above) |
7 days from delivery | The retention purge tick deletes the R2 object and nulls the pointer, so the e-mailed download link stops working; deleted earlier by customers/redact (every live archive of that shop, since a ZIP cannot be searched for one buyer) and by shop/redact |
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, or — for an abandoned failed shipment that never mints a waybill (never reaches a terminal state) — 90 days after the shipment was created | Cleared on a later successful mint; scrubbed by the retention purge (terminal shipments, and non-terminal failed shipments with no waybill) 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, shop-scoped | Not part of the 90-day PII purge; hard-deleted shop-scoped by shop/redact |
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; for a data request also a SHA-256 digest of the merchant contact address the download link was sent to, never the address itself) |
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) |
Financial/accounting records — retained per statutory accounting periods, deliberately survives shop/redact |
Not part of the PII purge; not deleted by shop/redact |
Shop-level configuration rows (shop_settings, shipping_mappings — delivery/weight/risk settings and native-rate mappings; carry no personal data) |
Lifetime of the installation (housekeeping) | Hard-deleted shop-scoped by shop/redact; not part of the 90-day PII purge |
| 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 |
Retention schedule — Ferry Social
Ferry Social holds a different kind of data: the content of other people’s messages. The periods below are enforced by a scheduled purge that runs on the same every-five-minute cron tick as the Shopify one, bounded per run, each category independent of the others.
One period, several clocks. Every category below is 90 days, but a conversation and an order do not age on the same thing: a conversation is measured from its last message, an order and its buyer from the terminal state of the shipment (delivered / returned / cancelled). Both clocks exist because deleting a conversation must not take the order with it — the order screen still has to render. A shipment that never reaches a terminal state stops holding its order after 90 days from the shipment’s own creation, so a failed waybill nobody retried does not keep a recipient’s details indefinitely.
Where a row cannot be deleted (a column is NOT NULL, or an order screen joins it), the personal
columns are cleared and a placeholder takes their place — the record remains, the person behind it
does not. One consequence is deliberate: a buyer who writes again after ninety days opens a new
conversation, because the identity that would have recognised them is gone.
| Data category | Retention period | Trigger to delete |
|---|---|---|
Conversation transcript (social_conversations.transcript_json — every message verbatim, the buyer’s and the seller’s), the order dossier and the unaddressed notes read out of it, and the buyer’s platform id on that conversation |
90 days after the conversation’s last activity | Scheduled purge: transcript emptied, dossier and notes cleared, the platform id replaced by a placeholder |
Verbatim quotes attached to what the buyer engaged with (conversation_engagements.fields_json — each item, size, quantity and price carries the sentence it was read from) |
Cleared with its conversation; a quote whose conversation no longer exists, 90 days after its own last change | Scheduled purge |
Escrowed order question (pending_order_boundary.payload_json — recipient name, phone, address and the buyer’s quoted sentence, held while Ferry waits for the seller to say whether a burst is a new order) |
Deleted with its conversation; an orphan, 90 days after it was raised | Scheduled purge deletes the row (an empty question is worse than none) |
Buyer identity from Meta (people.display_name, people.avatar_url — the address of the profile picture, never the picture — the platform id and any phone on the record) |
90 days after that person’s last activity | Scheduled purge: name replaced by a placeholder, the rest cleared. The row is kept because the seller’s order list joins it |
The person above the platform accounts (initiators.display_name, initiators.notes — the seller’s own free-text description of that buyer — and phone) |
Cleared once everything under them has been cleared: no un-purged platform account and no un-purged order | Scheduled purge |
Buyer record (buyers.name, phone, address_json, notes, tags_json) |
90 days after the terminal state of that buyer’s shipment (measured from its last poll); a buyer with no shipment at all, 90 days after the record’s own last change | Scheduled purge: name and phone replaced by placeholders (both are keys), address, notes and tags cleared |
Order draft (order_drafts.input_json — recipient name, phone, e-mail, address, city, office text and the buyer’s quoted sentence — and model_summary_json, the record of what the model was shown) |
90 days on the same shipment clock | Scheduled purge removes the named personal fields and the model summary; the commercial part of the order (items, cash-on-delivery amount, courier, weight, parcels) stays |
Meta message-id ledger (processed_messages — the raw message ids, one per incoming message, used so the same message is never processed twice) |
90 days after the id was seen | Scheduled purge deletes the rows. The table is global, not per-seller, so a seller’s own “delete all my data” does not touch it — the 90-day expiry is its only clock |
Test corpus (chat_fixtures.transcript_json, seed_candidates.snapshot_json — real conversations captured for testing the extraction) |
90 days | Scheduled purge deletes the rows; also deleted by the seller’s “delete all my data” |
Parked share question (share_parks.frames_json — the read of a chat the seller shared in, held while Ferry waits to be told which conversation it belongs to) and collected share frames (share_collect_frames.messages_json — the text read out of the shared pictures; never the picture) |
30 days | Scheduled purge deletes the rows |
| Un-signed-in share stash (Cloudflare KV — the shared picture itself, base64-encoded, plus its text, so a share survives the sign-in it interrupted) | 15 minutes (TTL) | Deleted the moment the share resumes; otherwise expires. This is the one place in Ferry Social where the bytes of somebody else’s picture are written down at all |
Shared returned-parcel events (buyer_reputation_events — the same pseudonymised network dataset as in the Shopify schedule above) |
365 days, auto-purged | Scheduled purge |
Blocked phone numbers (block_list — a normalised phone plus the seller’s short note) |
Until the seller removes the entry | Removed by the seller. An age-based deletion, and the list’s inclusion in “delete all my data”, are being added |
Label PDFs (R2 — name, phone and address printed on the label) and courier tracking payloads (tracking_events) |
Today in Ferry Social these are deleted when the seller cancels the shipment (the label) or deletes all their data (both); an age-based purge is being added to match the 90 days on the Shopify side | Shipment cancellation; “delete all my data” |
Seller’s own page posts and captions (page_posts) — the seller’s catalogue, not buyer data |
Lifetime of the account | Deleted by “delete all my data” |
Notification ledger (notification_events — event kind and a reference to a person; no message content) |
Lifetime of the account | Deleted by “delete all my data” |
| In-flight message buffer (Durable Object storage — messages waiting to be read together as one burst) | Transient | Cleared when the burst is processed |
| Seller account and connections (e-mail address, the connected Meta page and its encrypted access token, encrypted courier credentials, sender details, web-push subscriptions, app settings) | Lifetime of the registration | Account deletion is carried out by Prendit EOOD on request to privacy@ferrycommerce.com; a self-serve route is being added |
What deletes what, in Ferry Social. Two mechanisms exist: the scheduled purge above, and the seller’s own “delete all my data”, which removes every working row for that seller (conversations, engagements, buyers, people, initiators, orders, shipments, tracking events, parked and collected shares, page posts, the test corpus) and their label objects in R2, while deliberately keeping the account and its connections so the seller need not reconnect. There are no Shopify webhooks here —
shop/redactandcustomers/redactdo not exist in this product — and, as stated in §8 of the Privacy Policy, there is no buyer-facing request surface: a request reaches privacy@ferrycommerce.com and is executed by hand.
Deletion on request (Shopify mandatory webhooks — Ferry (Shopify) only)
Ferry honours Shopify’s GDPR webhooks:
customers/data_request— package what Ferry holds for that customer and deliver it to the merchant. The archive covers both the outbound and the return leg of every requested order: the label PDFs, the tracking events, the stored delivery/pickup/normalized address JSON, the derived risk assessment, and an inventory of what was found. Ferry e-mails the merchant’s contact address a signed download link valid for 7 days; the archive is deleted when that link expires. When Ferry holds nothing for the requested orders, the e-mail says so and carries no link.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 (Ferry Social): the purge described in the Ferry Social schedule is implemented — one scheduled job on the every-five-minute cron tick, each category its own statement, bounded per run so a backlog drains over successive ticks rather than being skipped. The two categories it does not yet cover are named in that schedule: label PDFs with courier tracking payloads, and the block list.
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 (Shopify) 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 is limited to cash-on-delivery orders with an outstanding payment; 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 the tenant (the Shopify shop, or the Ferry Social seller) and by
customer/order id are hard-deleted — subject to the
documented exceptions in the schedule (
gdpr_requestsaudit and financial records). - 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.
- 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.