Tenant config portability: the export/import bundle
In addition to the one-click upstream importers, the platform ships a portability pair for your own tenant configuration: an export endpoint that produces a single self-describing JSON document (the “config bundle”), and an import endpoint that applies that bundle to the same or another tenant. This page owns the bundle’s semantics — what it contains, how secrets are handled, and the version contract — while the upstream adapter layer that pulls from third-party CPaaS platforms lives on the imports and migration page.What the bundle is
A config bundle is a single JSON document (TenantConfigExport). It is
self-describing: the envelope at the top carries schema_version
(the EXPORT_SCHEMA_VERSION constant) plus exported_at, and the payload
carries the entity collections currently supported:
- organization-level settings (
organization_settings) - templates
- segments
- contact lists
- agents
- knowledge bases
- flows
- webhook endpoints
- keyword rules
- organization feature flags (
org_feature_flags)
:orgId in the route below is the organization whose configuration you are
exporting (or, on import, the target tenant you are writing to). The endpoint
names it because the portability pair lives on the platform’s admin surface, not
on the public API — it is an operator tool for cloning or restoring your own
configuration, not a customer integration endpoint.
The export side
Authorization gate
Exports are restricted to super-admins or the target organization’s owner. The check lives inExportService.requireExportPermission: a super-admin
passes immediately; anyone else must be the owner of the target organization —
otherwise the service throws 403 before any data is read. The parent admin
router already enforces super-admin, but the inner gate is a defence-in-depth
tripwire, so a bare admin call cannot stray across tenants.
Recursive secret redaction
The export deliberately strips secrets before the bundle leaves the API. The redactor (redactSecrets in ExportService) walks the whole bundle tree and
applies two rules:
- Any object key matching the pattern
/token|secret|api_key|password|credential/ihas its value replaced with the sentinel string<REDACTED>. - Any string value that is an envelope-encrypted ciphertext (prefixed
enc:v1:) is also replaced with<REDACTED>, regardless of the key name — a ciphertext is useless outside the source cluster, and the import side never wants to carry it across.
Audit fingerprint, not full bundle
The platform does not log the bundle contents — a tenant’s configuration may be large and shouldn’t be retained in an audit stream. Instead the exporter records a fingerprint: byte size, plus counts of templates, agents, and flows, alongside thetenant_config.exported audit entry. A Prometheus counter
and size histogram (recordSettingsExport) complete the observability loop.
Export route
TenantConfigExport JSON. Import requests cap the body
at 100 MB; beyond that, the chunked GCS import path takes over (out of scope
for the inline route).
The import side
The import route (POST /admin/tenant-config-export/:orgId/import) accepts the
same bundle plus a mode:
dry-run(default) — compute the diff and return it without writing anything. The diff carries per-row entries (create,update,skip,delete) plus an aggregate summary. The FE uses this to show “you’re about to change N things.”merge— idempotent upsert. Each entity is written by deterministic id projection (see below), so re-running the same source on the same target produces the same projected ids and updates rather than duplicates.replace— destructive wipe followed by insert. Forreplacethe service additionally requires a typed confirmation:confirm_org_namemust exactly match the target tenant’s name (or its id if the name is blank). This is the same tripwire the admin UI’s confirmation modal enforces — hardened server-side after a blank-named org could be wiped without it.
Size gate and batching
The route-level body limit is 100 MB. A large clone of an org with thousands of rows is applied as batched multi-row INSERTs, chunked to stay under the Postgres bind-parameter ceiling, and ON CONFLICT upserts keep re-runs idempotent. Phone numbers are never written by this pair — carriers won’t transfer ownership cross-tenant — and dangling references (a flow pointing at a missing segment, a keyword rule pointing at a missing agent) are surfaced as warnings in the diff rather than hidden failures.Schema-version contract
The bundle’sschema_version is stamped at export and enforced at import.
GET /admin/tenant-config-export/schema-version returns the currently supported
version, and ImportService.importBundle refuses a bundle whose
schema_version does not match it — a 400 with SCHEMA_VERSION_MISMATCH —
so a malformed or stale bundle fails loudly instead of partially applying.
Worked example
Export, edit, dry-run, and merge — a curl round-trip:<REDACTED>, any configuration that depends on a real credential —
webhook signing secrets, API keys, passwords — will show the placeholder after
import. The webhook-endpoint writer normalizes a <REDACTED> secret to null
and surfaces a warning in the diff, so plan to re-issue or re-enter those
credentials on the target before the imported endpoint goes live.