Skip to main content

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)
The :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 in ExportService.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/i has 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.
The redactor returns a fresh tree; the original database result is never mutated.

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 the tenant_config.exported audit entry. A Prometheus counter and size histogram (recordSettingsExport) complete the observability loop.

Export route

Returns the redacted 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. For replace the service additionally requires a typed confirmation: confirm_org_name must 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’s schema_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:
When redacted secrets must be re-entered. Because the export strips every secret to <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.

Authority split

This page owns bundle semantics — the export/import pair and its version contract. The upstream-importer page Import and migration lifecycle owns the third-party source adapters (Twilio, Telnyx, Klaviyo, MessageBird, Front). For the egress catalog as a whole (audience CSV, vCon, reverse-ETL, WORM archival), see the export families model.