Tenant Offboarding Runbook
Use this runbook when a client's subscription ends and their data and credentials need to be removed from VERA.
Offboarding from the admin dashboard
The /admin dashboard includes a browser-based offboarding flow. This is the recommended path for non-technical operators.
How to offboard from the dashboard
- Open
/adminand enter yourADMIN_SECRET. - Click Load tenants.
- Find the tenant row and click Offboard (the purple button in the last column). Use Delete only for empty or typo tenants — Offboard is the safe path for clients with real data.
- Step 1 — Prepare bundle. Click Prepare bundle. VERA prepares a complete backup of the tenant's data. This step is read-only; nothing is deleted yet. If the backup fails, the UI shows the error and asks you to resolve it before proceeding. (If the data is truly unrecoverable, use the CLI with
--allow-partial-exportinstead.) - Step 2 — Download bundle. A summary table shows row counts per sheet tab and Postgres table. Click Download bundle (.zip) to save the backup to your machine. The confirm button in step 3 is disabled until you click Download.
- Step 3 — Confirm deletion. Type the tenant id exactly (case-sensitive) and click Confirm deletion. This step is irreversible: Postgres rows for the tenant are deleted and the tenant entry is removed from the tenants file.
- After confirmation, a success screen shows the path to the backup bundle on the server and the manual checklist of steps that cannot be automated (see After the script — manual steps below).
When to use the CLI instead
Use scripts/offboard-tenant.js directly when:
- A sheet tab fails to export and you need
--allow-partial-export. - You need a custom bundle output path (
--out=<dir>). - You need to skip the Postgres pass (
--skip-pg). - You need a dry run to inspect row counts without touching anything (
--dry-run).
The web UI is the safe path. The CLI is the expert path.
When to offboard
Offboard a tenant when:
- A client subscription has ended and they will not be returning.
- A test or demo tenant is no longer needed.
- A tenant was created by mistake and has never been used.
Do not offboard a tenant that is temporarily inactive — simply stop sending them the Slack reminders and leave the configuration intact.
The script
bash
node scripts/offboard-tenant.js --tenant=<id> [--dry-run] [--skip-pg] [--out=<dir>]| Flag | Effect |
|---|---|
--tenant=<id> | Required. The tenant id (matches the id field in the tenants file). |
--dry-run | Walks the entire flow, prints what would happen, writes nothing. Run this first. |
--skip-pg | Skips the Postgres export and deletion pass. Use only if you know the tenant was never added to PG_SHADOW_WRITE_TENANTS. |
--out=<dir> | Custom path for the backup bundle. Default: ./offboarded-tenants/<id>-<YYYY-MM-DD-HHmmss>/. |
--allow-partial-export | Danger. If one or more sheet tabs fail to export, proceed to the confirmation gate anyway instead of aborting. The bundle README marks each failed tab as "EXPORT FAILED". Do not use this unless you are certain the failed data is unrecoverable or expendable. |
Recommended sequence
bash
# 1. Dry run first — inspect the output, verify row counts look right
node scripts/offboard-tenant.js --tenant=acme --dry-run
# 2. Real run — type the tenant id when prompted to confirm
node scripts/offboard-tenant.js --tenant=acmeWhat the script does
The script is built around a single principle: do no harm. Every byte of the tenant's data is exported to a portable bundle before anything is deleted. If you change your mind, abort at the confirmation prompt and the bundle stays on disk.
Step-by-step
Pre-flight — Loads the tenant from the tenants file. Prints a summary (sheet id, audit channel, pg status). Fails fast if the tenant id is not found.
Sheets export — Fetches all 14 operational tabs from the tenant's Google Sheet using their own service account credentials. Each tab is written as a CSV in
<bundle>/sheets/.Postgres export (if applicable) — If the tenant is in
PG_SHADOW_WRITE_TENANTS, reads all 14 tables from Postgres and writes them as CSVs in<bundle>/postgres/.Tenant config — Writes two files in
<bundle>/meta/:tenant-config.json— sanitised, safe to share with the client. Credentials are stripped.tenant-config.full.json— full record including tokens and service account key. Internal only — do not share.
Bundle README — Writes
<bundle>/meta/README.mddescribing the bundle contents with row counts.Confirmation gate — Prints a summary of what was exported and asks you to type the tenant id to proceed. Anything else aborts.
Postgres deletion — Deletes all rows for this tenant from all 14 tables inside a single transaction. Rolls back on any error.
Tenants file mutation — Removes the tenant entry from the tenants file (
TENANTS_FILEenv var, defaults totenants.dev.json).Manual checklist — Prints the steps that cannot be automated (see below).
Bundle layout
offboarded-tenants/<tenant-id>-<YYYY-MM-DD-HHmmss>/
sheets/
01-time-entries.csv
02-projects.csv
... (14 files total)
postgres/ (only present if tenant was pg-allowlisted)
time_entries.csv
projects.csv
... (0–14 files — only tables with at least one row get a CSV)
meta/
tenant-config.json ← safe to share with client
tenant-config.full.json ← INTERNAL ONLY — contains credentials
README.mdThe column order of each CSV in sheets/ matches the schema documented at https://getvera.site/reference/schema/sheets
After the script — manual steps
The script prints this checklist at the end. Do not skip these steps.
Restart the VERA service so Bolt drops the tenant's Socket Mode connection and the in-memory tenant cache is rebuilt without them.
Uninstall the Slack app from the client's workspace. The client's Slack workspace admin must do this in Slack > Settings > Manage apps.
Revoke Google service account access to the client's Google Sheet. Either remove the service account's Editor access in the Sheet's sharing settings, or transfer ownership of the sheet back to the client.
Archive the audit channel in Slack. This is the channel VERA posted notifications to — archive it so it no longer receives messages.
Zip and hand over the bundle. Zip
offboarded-tenants/<tenant-id>-<YYYY-MM-DD-HHmmss>/, but excludemeta/tenant-config.full.json(it contains credentials). Send the zip to the client as their data handover.Remove from Railway env. If the tenant id was in
PG_SHADOW_WRITE_TENANTS, remove it from the Railway environment variable and redeploy.
If you change your mind
If you ran the script, aborted at the confirmation prompt, and then decide you want to keep the tenant:
- The bundle in
offboarded-tenants/can be ignored or deleted — nothing in production was changed. - Restart VERA if you already removed the tenant id from
PG_SHADOW_WRITE_TENANTS.
If you confirmed and the script ran to completion on the same day:
- The Google Sheet is untouched — you still have all the data there.
- The Postgres rows are gone. To restore, re-add the tenant to the tenants file, re-add to
PG_SHADOW_WRITE_TENANTS, restart VERA, and runscripts/backfill-tenant.js --tenant=<id>to repopulate Postgres from the Sheet. - The tenants file has been mutated — restore the entry from
meta/tenant-config.jsonin the bundle.
Troubleshooting
A sheet tab failed to export
The script prints the failing tab and error message, then exits before the confirmation gate:
❌ The following sheet tabs failed to export:
02. Projects!A:T → Quota exceeded for quota metric 'read_requests'
Aborting — cannot proceed to deletion with an incomplete backup.
Fix the error above and re-run, or pass --allow-partial-export to proceed anyway.
Partial bundle preserved at: ./offboarded-tenants/acme-2026-05-12-143022Recommended action: Fix the root cause (quota, permissions, connectivity) and re-run. The partial bundle on disk can be deleted — nothing in production was changed.
If the failure is truly unrecoverable and you still need to proceed, use --allow-partial-export. The script will continue to the confirmation gate. The bundle README will mark the failed tab(s) as ⚠️ EXPORT FAILED. Do not hand an incomplete bundle to the client without disclosing what is missing.
Notes
- The script never posts to the audit channel (it is an out-of-band operator command, not a VERA action).
- The script never hard-deletes the client's Google Sheet. The Sheet is owned by the client's Google account; VERA only had Editor access via the service account.
tenant-config.full.jsoncontains the service account key JSON and Slack tokens. Treat it like a password file.- Postgres tables with 0 rows produce no CSV file in
postgres/. Themeta/README.mdrow-count table will show0for those tables — that means the table was queried successfully and was empty, not that it was skipped.