A self-serve export endpoint and the renewal call that follows
Consider a logistics SaaS with a hundred-odd tenant organisations. Each one runs dispatch plans, vendor records, and a few years of shipment history through the app. Around month eleven of an annual contract, a few customers start asking a version of the same question: if we leave, what happens to our data?
The honest answer, in most such products, is: we can dump it for you, give us a week. That answer makes renewals harder. It signals that the data is held rather than managed, and it gives the customer's IT team a reason to escalate the question internally before signing again.
The fix is a self-serve export endpoint. It is a few days of work, and in our experience it changes the tone of renewal conversations more than most features that take a quarter.
The endpoint is unglamorous. It accepts a date range and a resource type, enqueues a job, and produces a signed URL pointing to a zipped set of CSVs on object storage. The CSVs are flat — shipments, vendors, line items, addresses — with foreign keys preserved so a downstream system can reconstruct the relationships. No nested JSON, no proprietary format.
The permission check is the part that matters in a multi-tenant setup. The export runs as the requesting user, not as a system admin. That means a warehouse manager can export shipment records for their own region but cannot pull vendor contracts from a tenant they do not belong to. We enforce this at the query layer, not just at the route.
@require_scope("data:export")
def create_export(request, tenant_id, resource, date_from, date_to):
if request.user.tenant_id != tenant_id:
raise Forbidden("cross-tenant export denied")
if resource not in request.user.exportable_resources:
raise Forbidden("resource not in scope")
job = enqueue_export(
tenant_id=tenant_id,
user_id=request.user.id,
resource=resource,
date_from=date_from,
date_to=date_to,
)
return {"job_id": job.id, "status_url": f"/exports/{job.id}"}The job itself is a background worker that streams rows from the read replica, writes CSVs to a temp directory, zips them, uploads to S3, and sends an email with a link that expires in 72 hours. For a tenant with, say, 200,000 shipment rows, that should finish in a couple of minutes. We cap the date range at 24 months per export to keep memory predictable, and customers who need everything can run two jobs.
There is a tradeoff here. A full export in a portable format makes it easier for a customer to leave. We accept that. The alternative — making departure painful — is a weak retention strategy and a bad relationship. Customers who want to leave will leave; the question is whether they leave feeling respected or feeling locked in. That difference shows up in whether they recommend you to the next person they work with.
What tends to surprise people is how many customers use the export for reasons other than leaving. One tenant will pull a monthly export to feed an internal BI tool. Another will take a snapshot before a quarterly audit. The export endpoint becomes an integration point, not just an exit door.
The renewal conversation shifts. When a customer asks about data portability, the answer is a sentence: go to Settings, click Export, pick your date range. There is no week-long wait, no internal escalation, no sense that the vendor is reluctant. The question gets answered in the same call where pricing is discussed, and the call moves on.
We log every export in an audit table — who requested it, what resource, what date range, when the link was downloaded. Tenants with compliance requirements ask for that log. We give it to them as a CSV, naturally.
The feature is not technically interesting. It is a query, a zip file, and a signed URL. But it removes a source of friction that is quietly making renewals harder than they need to be.