How your data flows
Cancel OS reads bank statements. That deserves a straight answer about what happens to them, not a policy written to be unreadable.
Your statement file never leaves your browser
When you import a CSV, the parsing happens on your machine. The file is read, split into rows, and the dates and amounts are interpreted entirely in your browser — there is no upload step, and no copy of the file is stored anywhere.
Some PDF statements are pictures rather than text — a printed page that was scanned. Where a page has no text we can read, we read it from the image, and that happens on your machine too: the recogniser is a piece of software downloaded to your browser the first time you need it, from our own site. The page image, and the text recognised from it, exist for a few seconds inside the tab and are never uploaded and never stored. Rows read this way are marked in the review screen, because recognising characters from a picture is less reliable than reading them, and you should check them.
Working out which of those rows look like subscriptions also happens on your machine. Your browser downloads our merchant list — a public list, the same for everyone — and matches descriptors against it there. No statement row is sent in order to find your subscriptions, and you see the results before any row leaves your browser. Two small product-analytics events do: once a file is read, how many rows it has and whether it was a CSV or a PDF (or, if reading failed, a coarse reason such as “unreadable”). Never a row, a merchant or an amount.
Then you press Save, and the payments we read are sent. For each outgoing payment: its date, description, amount and currency, and — for a PDF statement — which page it came from and whether it was read from a scanned image, with the recogniser’s confidence for that page. That is every outgoing payment we read, not only the ones we identified as subscriptions. The subscriptions you kept and the file’s name go with them. The complete list of fields is under Technical detail below. That is the honest shape of it: reading and finding are local, keeping is not. This used to happen twice, once to detect and once to save, and the first of those is gone.
What we intend to leave out, and how. The readers are built to send outgoing payments only: money coming in, rows your bank marks as pending, and lines that are not transactions are dropped before anything is sent, and a row we could not read never becomes a payment at all. How that works depends on the format. For a CSV, you tell us which columns hold the date, description and amount, and a column you do not map — a balance, say — is never read. A PDF has no columns to map: we recognise transaction lines and drop lines that look like balances, totals or page headers. Statements vary, and reading them can go wrong — a bank’s layout or its way of marking money in and out can be misread — so this is how the readers are designed to behave, not a guarantee that every row is classified correctly.
So “detection is local” is true and “your statement never reaches our server” is not. If you save an import, we receive its outgoing payments. Unless you turn on Private Mode, which is the next section — that is the one setting that changes what crosses the wire, and not only what we keep.
What we send, and why
- The statement file itself (CSV or PDF)Never sent
- Outgoing payments: date, description, amount, currency; for PDFs, page and scan detailsSent once, when you save — every one we read, including those we did not identify as subscriptions
- Incoming money, pending rows, lines that are not transactionsLeft out by design before saving — reading a statement can still misclassify a row
- Rows we could not readNever become payments, so never sent
- Your merchant list and pricesStored, so the dashboard works
- Bank name, account number, balanceNot sent — a CSV uses only the columns you map; a PDF reader keeps transaction lines and is built to drop headers and balances
Matching a descriptor like SQ *SPOTIFY P0A1B2C3 to a merchant needs our cancellation database, and for a long time that was the reason detection ran on our server: the rows had to go to where the list was. We have turned that around and send you the list instead. It travels in the direction that costs you nothing — it is public, identical for every account, and says nothing about you.
One thing to be clear about, because it is the part people get wrong. Your browser also asks us for a small summary of the subscriptions you already track, so an import can tell a renewal from something new. That is your own data coming back to you, and it is never cached; the merchant list is cached, because it is not yours and not private.
Private Mode: keep the answer, drop the evidence
With Private Mode on, we keep the subscriptions we found and the statement rows behind them stay in your browser. They are not written to our database, and — unlike before — they are not sent to us either.
Being exact about what that does and does not mean. This used to be a retention setting only: every parsed row travelled to our server and was then dropped instead of stored. That was true of the database and not of the wire, and the difference mattered. It now applies to both. What we receive when you save in Private Mode is a count of the rows, and the subscriptions you approved on the review screen.
So there are still things we receive, and this page will not pretend otherwise. For each subscription you approve:
- What it is — its name, the merchant we matched it to (if any), its amount, currency and cadence.
- When — the dates of its first and last charge, the expected next renewal, and whether this looks like its first charge.
- How we found it — how many charges we found and where they sit in the file, how confident we were and the reasons, and which kind of source it came from.
- How we recognise it again — the key we match it by, built from the merchant when we recognise it and from its statement text when we do not.
- For cancelling — the one line of statement text described below.
Plus the file’s name and the number of rows. These are categories; the complete field list is under Technical detail. That is not “nothing leaves your device”, and we do not say it is.
We keep one line of statement text per subscription — enough to know whether Apple or Netflix took the money, because cancelling in the wrong place does not work. That is the minimum needed for the product to still function.
The trade-off: without the raw rows we cannot re-run detection later to find something we missed. You would re-import instead.
Technical detail: exactly what an import sends
Field names as they appear in the request. Optional fields are sent only when they apply.
While you are reviewing (before Save)
- Merchant list request: the version of the list your browser already holds, if any.
- Your existing subscriptions summary: a request with no body.
- Product analytics:
row_countandformatonce a file is read;formatandfailure_kindif reading fails. - A check of your current account and Private Mode setting, with no body, just before Save.
The Save request
filename(file names, joined for several files) andassumedPrivateMode.transactions— with Private Mode off, one entry per outgoing payment read; with it on, empty, androwCountis sent instead.accepted— one entry per subscription you approved.
Each entry in transactions
postedOn,description,amount,currency.money— the same amount exactly:minorUnits,currency,exponent.- PDF only:
sourcePage; for a page read from an image,viaOcrandocrConfidence(the recogniser’s confidence for that page, 0–100).
Each entry in accepted
- Identity:
name,merchantId,matchKey,legacyMatchKey. - Price and cadence:
amount,money,currency,frequency. - Dates:
firstChargedOn,lastChargedOn,nextRenewalOn,isFirstCharge. - Evidence:
occurrences,transactionIndexes(positions of its charges in the file),confidence,evidence(statement, email or billing page),admission,reasons(merchantScore,amountScore,intervalScore,missedPeriods,amountVaries) anddimensions(extraction,merchant,recurrence,match,price,renewalDate,status, each aleveland itsreasons). - Links and notes:
attachedTo(an existing subscription it corroborates),note. - Only for a subscription found in email receipts:
messageIdsandoccurrenceEvidence(messages,distinctDays,duplicateDays,duplicateDaysExplainedByThread,sameDayDistinctSubjects,crossDayThreadRepeats,duplicateSignalsSeen). - Private Mode only:
statementDescriptor— the one line of statement text kept for cancelling.
Who else sees any of it
- Supabase (database and sign-in)Everything you store. Row-level security means your rows are only readable by you.
- PostHog (product analytics)Events like “imported a statement” with counts. Never a merchant name, an amount, or a descriptor.
- Sentry (error reporting)Stack traces when something breaks. Configured not to send request bodies or cookies — but a database error can quote the row that caused it, so one statement line can appear in an error report.
- Resend (email)Your address and the alert text, for renewal reminders only.
- Advertisers, data brokers, anyone elseNothing. There is no such integration to disable.
Getting your data out, or getting rid of it
Everything is exportable as a single JSON file, and removable at four different granularities: one import, all statement history, all subscriptions, or the entire account. Deleting the account removes every row that references it, in the same transaction.
The settings page has a statement-history retention choice. It is currently inactive: automatic deletion is switched off, and nothing is scheduled to delete statement rows. Your choice is saved but has no effect. Rows stay until you delete them yourself from your privacy and data controls. We would rather say that than let a setting imply a deletion that is not happening.
Privacy & data controlsWhat we have not solved
Being straight about the gaps is part of the point. Our servers can technically read what is stored — Private Mode reduces what exists, it does not encrypt it against us. Cancellation route data is shared between all users, so following a route tells us that route was used, though not by whom on the public record.
Detection runs in your browser now, but saving an import still sends us every outgoing payment we read, so “we never see your rows” remains false and we still do not say it. Moving the save itself is a much larger change and has not been started.
The other one on this list has been fixed, and the fix has an edge worth naming rather than rounding off. A database rejection used to explain itself by quoting the offending row, which put one statement line — merchant, amount, date — into an error report. Those reports are now stripped: for anything the database raised, the detail, the hint, any wrapped cause and the original message are all dropped, and what remains is the error code and the constraint name, which are facts about our schema and not about you.
The edge: an error from our own code keeps its message, and a wrapped database error is caught by dropping the cause chain. That is a deliberate boundary rather than a blanket promise — our messages describe code paths and do not carry row values, and if one ever did, the stripping above would not be what stopped it.