How to make it easy to leave your SaaS

By

Design a useful SaaS export with stable IDs, attachments, and import checks, then handle account deletion through a durable, verifiable process.

~~~

If someone puts years of work into your application, they should be able to take that work elsewhere.

An export button helps only if the downloaded files are useful. A list of titles without notes or attachments leaves part of the person’s work behind.

In how to compete with software clones, I argued for making departure straightforward. Let’s turn that into a product feature for a small reading-list app. People save articles into collections, write notes, and attach files.

We’ll design an export another application could import, then work through what account deletion needs to finish.

Decide what belongs in the export

Start with one ordinary account and list the information its owner created.

For our app, that includes saved article URLs, notes, collection membership, archive status, and attachment files. Keep timestamps too. A person may want to sort by the day they saved something after moving it elsewhere.

Leave credentials and session tokens out. Internal moderation records or another person’s private notes need a separate ownership decision; don’t dump every database table because it is convenient.

Describe the supported fields in a short format document. Give the format a version so a later importer can distinguish a changed structure.

Preserve relationships with stable identifiers

Use JSON for the complete structured export. Offer CSV separately if people want to open their article list in a spreadsheet.

Our archive has this layout:

reading-list-export/
  data.json
  FORMAT.md
  attachments/
    file_8.txt

Here is a complete small data.json:

{
  "formatVersion": 1,
  "exportedAt": "2026-10-17T10:00:00Z",
  "collections": [
    { "id": "collection_3", "name": "JavaScript" }
  ],
  "articles": [
    {
      "id": "article_12",
      "url": "https://flaviocopes.com/javascript/",
      "title": "The JavaScript Handbook",
      "savedAt": "2026-10-15T09:30:00Z",
      "archived": false,
      "collectionIds": ["collection_3"],
      "notes": "Return to the section about functions.",
      "attachmentIds": ["file_8"]
    }
  ],
  "attachments": [
    {
      "id": "file_8",
      "path": "attachments/file_8.txt",
      "originalName": "study-notes.txt",
      "mediaType": "text/plain",
      "bytes": 12
    }
  ]
}

For this fixture, file_8.txt contains Read again. followed by a newline. An attachment belongs in the archive, rather than remaining a URL that stops working when the account closes.

Document that IDs identify records within the export. An importer can assign new database IDs and maintain an old-to-new mapping. Two collections can share a name, so joining records by their display names would lose information.

Include checksums for files in a production format so the importer can detect corruption. Treat archive paths as untrusted during import: reject absolute paths and paths that escape the destination directory.

Make export generation restartable

For a small account, a request might generate the archive immediately. Larger accounts need a background job.

Use a contract like this:

RequestResult
POST /api/exportsCreate a job; return its ID and pending status
GET /api/exports/export_42Return pending, running, ready, or failed
GET /api/exports/export_42/downloadAuthorize the owner and return the private archive

Store job state durably. Persist completed batches and write the archive to temporary storage before marking it ready. A worker crash should leave a job that can resume or restart cleanly, with an attempt limit and a visible failure.

Choose what consistency means. A stable export should describe one snapshot, even if someone edits an article while it runs. A database snapshot or a materialized copy of the export data can provide that boundary. Saving only record IDs doesn’t freeze their changing contents.

Process that snapshot in ordered batches using a stable cursor. Offset pagination over a changing live list can skip or repeat records. Report the snapshot time so the owner knows which later changes are absent.

Keep downloads private

Authorize creation, status checks, and downloads against the authenticated account. An unpredictable job ID doesn’t replace those checks.

For a team workspace, require permission to export that workspace’s data. An ordinary member shouldn’t gain an administrator’s view by changing an ID in the request.

Keep generated files in private storage. For example, the S3 documentation describes presigned URLs as bearer tokens. If you use a short-lived signed download URL, treat it accordingly: anyone holding it can use it until it expires. Avoid logging it or embedding it in public pages.

Show the expiry in the interface and let the owner regenerate an expired export. Remove expired files through a cleanup job. The export endpoint and cleanup job should agree on whether an archive still exists.

For an API supporting ongoing copies, offer revocable read-only credentials and document the same stable IDs. Explain pagination and how clients detect deleted records, for example through a change feed containing deletion markers. A sequence of successful list requests can otherwise leave the client keeping records the owner already removed. Keep this incremental sync separate from a snapshot export.

Fig 1 · 1/3Export, download, and restore
Snapshot captured

Prove someone can use the files

Write a small import fixture before calling the export finished.

Import the archive into an empty test account. Compare the article count and exact notes. Check collection membership, archive status, and attachment contents. Include non-English text, an empty collection, and an article belonging to several collections.

Test more than one page of records, plus an interrupted export and an expired download. A missing attachment should fail the job visibly rather than produce a successful archive with silent omissions.

Try the same status and download requests as another account. Both should be denied. Record counts alone wouldn’t catch a data leak.

Give deletion a durable job too

Account deletion needs a clear confirmation and recent authentication. Describe what will disappear, what happens to a shared workspace, and what happens to billing before the owner confirms.

Offer export first. If someone chooses export followed by deletion, finish the export and let them download it before starting deletion. Keep those steps explicit; a deletion request alone shouldn’t force someone to download an archive.

After confirmation, create a durable deletion job before invalidating the account’s sessions. Stop new writes and outgoing automation. Cancel future billing through the billing provider, verify the result, and retain enough job state to retry if that provider is unavailable. Account deletion, subscription cancellation, and refunds are different operations.

Then remove the owned records and files, including generated exports, search indexes, and caches. Handle shared records according to their ownership rules. A departing member shouldn’t accidentally delete everyone else’s workspace.

Each step needs to be safe to retry. Record completion per step so a crash doesn’t leave an invisible half-deleted account. Keep the remaining job record minimal and access-controlled.

Explain separately how backups and any necessary retained records are handled. Deleting live rows doesn’t erase older backups. A restore procedure also needs to reapply completed deletions before restored data becomes accessible.

Test this in a disposable environment with a fake billing provider. Interrupt the worker after cancellation, then restart it. Verify that it finishes removal without creating another charge or restoring access. Only show deletion as complete when the defined steps have finished; otherwise show the remaining status and a way to get help.

Tagged: Business · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about business: