> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenant config portability: the export/import bundle

> How the tenant config export/import pair works — the self-describing JSON bundle with a schema-version stamp, the export-side authorization gate and recursive secret redaction, the three import modes (dry-run, merge, replace), and the version contract that refuses mismatched bundles.

# Tenant config portability: the export/import bundle

In addition to the [one-click upstream importers](/concepts/imports-migration-model),
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](/concepts/imports-migration-model).

## 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

```
GET /admin/tenant-config-export/:orgId/export
```

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:

```bash theme={null}
curl -H "Authorization: Bearer <admin-token>" \
     "https://api.orbit.devotel.io/admin/tenant-config-export/$(ORG_ID)/export" \
  > bundle.json

# Edit bundle.json (e.g. rename a template), then preview the change
curl -X POST -H "Authorization: Bearer <admin-token>" \
     -H "Content-Type: application/json" \
     -d '{"mode":"dry-run","bundle":'"$(cat bundle.json)"'}' \
     "https://api.orbit.devotel.io/admin/tenant-config-export/$(ORG_ID)/import"

# Apply the changes idempotently
curl -X POST -H "Authorization: Bearer <admin-token>" \
     -H "Content-Type: application/json" \
     -d '{"mode":"merge","bundle":'"$(cat bundle.json)"'}' \
     "https://api.orbit.devotel.io/admin/tenant-config-export/$(ORG_ID)/import"
```

**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](/concepts/imports-migration-model) 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](/concepts/export-families-model).
