Guides

Import & Export

Move forms, email templates, and newsletter settings between environments — including Package API token auth for CI.

Move forms, reusable email templates, and (from package schema version 2) newsletter settings between environments — for example staging → production — as a single JSON package. Subscriber import is a separate, CSV-based flow; see Mailing Lists & Subscribers.

Automation uses Package API tokens (Bearer)

For CI and other external automation, set a token for each direction:

SettingOpens
CampaignCanvas:PackageApi:ExportTokenExport only. Set it on the environment you export from (staging).
CampaignCanvas:PackageApi:ImportTokenImport only. Set it on the environment you import into (live).
CampaignCanvas:PackageApi:TokenBoth. The original single token still works, but separate tokens are safer.

Fill them from environment variables (for example CampaignCanvas__PackageApi__ImportToken), User Secrets, or Key Vault — never commit a real token. Keep empty placeholders in appsettings.json.

A token must be at least 32 characters. A shorter one is ignored, as if no token were set, and the application log says once which setting is too short (never its value). Generate one with:

openssl rand -hex 32

Give each environment only the token for the direction it serves, with a different value per environment. A token leaked from staging can then read staging's own export, but can't import anything into live. The single Token opens both endpoints wherever it's set, so if staging and live share its value, a leak from staging can import into live.

Send the token as:

Authorization: Bearer <your-token>

Prefer Bearer so GitHub Actions / Azure DevOps can inject it like other APIs. An alternate header X-CampaignCanvas-Package-Token is also accepted. A token only opens its own endpoint: an export token sent to the import endpoint gets a 401.

curl -sS -H "Authorization: Bearer $CC_EXPORT_TOKEN" \
  "https://staging.example.com/api/v1/campaigncanvas/public/package/export" \
  -o package.json

Responses

StatusMeaning
200The export package, or the import result
400Import refused and nothing imported: no package in the body, a strategy other than 0–3, an unsupported schema version, or a package that fails validation (see Importing). error says why.
401Missing or wrong token for that endpoint. Counts toward the failed-attempt limit.
409Strategy 2 found aliases that already exist; nothing was changed. conflicts lists them.
429Too many failed attempts from this address. Retry-After gives the seconds left. Refused even with a correct token.
503No usable token is set for that endpoint (missing, empty, or shorter than 32 characters). The body is {"error":"Package API is not available."}

Failed-attempt limit

After 10 failed attempts (a missing or wrong token) from one address within 5 minutes, that address gets a 429 until those 5 minutes are up — even with the correct token. The 5 minutes start at the first failure, and a successful call doesn't reset them. An IPv6 address is counted by its /64. Each failed attempt is logged as a warning, never with the token.

The limit uses the address of the connection itself and doesn't read X-Forwarded-For. Behind a reverse proxy or load balancer, run UseForwardedHeaders in the host, trusting only your proxy; otherwise every caller looks like the proxy and shares one count, so a few bad requests from anyone lock CI out too. The counts are kept in memory per process: they reset on restart and aren't shared between load-balanced servers. The limit slows guessing from one address; the real protection is a long random token.

Public vs backoffice

  • External automation (CI) — GET/POST /api/v1/campaigncanvas/public/package/{export|import} with the Package API tokens above. This is not Umbraco Automate.
  • Backoffice (editors) — Management API GET/POST /campaigncanvas/package/{export|import} with a logged-in Umbraco user. Conflict strategy is chosen in the UI.

What is included

IncludedNot included
Forms (schema, settings, workflows), reusable marketing email templates, and newsletter settings (package schema version 2) Submissions, subscribers, campaigns, themes, mailing-list definitions, ESP/Vouch keys

Export always strips these from forms, so they don't appear in the JSON or CI artifacts:

  • Captcha secret key — captchaSecretKey. The provider and site key stay; they're public.
  • Workflow credentials — the webhook secretValue, plus any botToken, signingSecret, apiKey, password, token or secret.
  • Workflow destinations — the webhook, Slack, Teams and Discord url, and the to, cc and bcc of email workflows. They decide where submissions are sent, and they differ per environment anyway.

An import is configuration only and never sends anything. The public Package API import always ignores includeSecrets, so incoming credentials and destinations are discarded and the destination's own stay on that environment:

  • Existing forms (Overwrite) keep the destination's captcha secret key and workflow credentials and destinations. Each workflow in the package takes them from a workflow of the same type on the destination, matched by id first, then by position. A workflow with no counterpart ends up without them.
  • New forms are created with those fields empty. An enabled webhook or email workflow without a URL or recipients is skipped when the form is submitted, so the import result lists each one under warnings, and the backoffice import shows them under Still to set up here. Fill them in after the first import.
  • A new form with a captcha provider but no secret key (neither on the form nor in appsettings.json) gets a warning too: it rejects every submission until a key is set. See Anti-Spam & Security.
  • When an overwrite changes a form's captcha provider or site key, a warning reminds you to check that the destination's secret key still matches.

So an export is not a full backup: restoring one doesn't bring back webhook URLs, recipients or captcha secret keys. Only the backoffice Management API accepts includeSecrets: true, for a package you assembled yourself with those fields added; never use it for shared CI artifacts.

Exporting

Public: GET /api/v1/campaigncanvas/public/package/export
Management: GET /campaigncanvas/package/export

Returns a PackageExport document (package schema version 2):

{
  "version": 2,
  "exportedUtc": "2026-04-21T10:30:00Z",
  "forms": [ … ],
  "emailTemplates": [ … ],
  "newsletterSettings": { … }
}

Package schema version 1 files (forms + templates only) still import; newsletter settings are skipped. Versions above 2 are refused.

Importing

Public: POST /api/v1/campaigncanvas/public/package/import
Management: POST /campaigncanvas/package/import

Send the package plus a conflict strategy:

StrategyBackoffice optionMeaning
0 OverwriteOverwrite existingOverwrite everything in the package that already exists (by alias); create missing aliases
1 Add missing onlyOnly import missingSkip aliases that already exist. The default when strategy is left out.
2 Fail if anything conflictsFail if anything conflictsNothing is written if any alias already exists (409). A package without conflicts is imported for real; it isn't a dry run.
3 Overwrite if newerAPI onlyOnly replace what's already there when the package is newer; create missing aliases

Any other strategy number is refused with a 400, never read as Overwrite.

With strategy 3, an existing form or email template is updated only when the package row's UpdatedUtc is strictly greater than the destination's (equal or older → skipped). That comparison is all the package timestamp is used for: whatever is applied gets the time of the import as its UpdatedUtc, just like Overwrite. Missing aliases are still created.

Newsletter settings are only updated by strategies 0 and 2; with 1 or 3 the destination's settings are left as they are.

Public import example (Add missing only), using a package from export:

curl -sS -X POST \
  -H "Authorization: Bearer $CC_IMPORT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq '{ package: ., strategy: 1, overwriteEnvironmentFields: false }' package.json)" \
  "https://prod.example.com/api/v1/campaigncanvas/public/package/import"

Email-template ids are remapped on import so form workflows keep pointing at the right template.

Promote staging → prod only behind a human gate, and treat exported packages as sensitive configuration.

For newsletter settings, from-email, reply-to, and preferences URL stay on the destination by default. Set overwriteEnvironmentFields: true only when you mean to promote those three values. Secrets and subscribers stay out of the artifact.

Validation

A package is untrusted input. Before anything is written, every form is checked, on the public and the backoffice import alike and whatever the strategy:

  • alias — 1–100 characters, starting with a letter or digit, then only letters, digits, - and _
  • settings.captchaSiteKey, when set — up to 128 characters of letters, digits, - and _

A package that lists a form alias or an email-template alias twice (regardless of case), or has an empty (null) entry in forms, emailTemplates or a form's workflows, is rejected too. One bad value rejects the whole package with a 400 and nothing is imported; error names up to ten offenders.

Forms and templates are matched by alias, never by id. If a new form or template arrives with an id that already belongs to a different one here, it's imported under a new id and a warning says so; it never overwrites the other one.

Result

{
  "success": true,
  "formsCreated": 5, "formsUpdated": 2, "formsSkipped": 1,
  "templatesCreated": 3, "templatesUpdated": 0, "templatesSkipped": 0,
  "settingsCreated": 0, "settingsUpdated": 1, "settingsSkipped": 0,
  "conflicts": [],
  "warnings": [
    "Form 'contact-us': captcha is enabled but the form has no secret key. Set one on the form or configure a global secret in appsettings, otherwise its submissions are rejected."
  ]
}

warnings lists what still needs setting up on the destination. When an import is refused, success is false and error says why.

Audit trail

Every export and import is recorded in the CampaignCanvas Audit log (PackageExported / PackageImported), from CI and the backoffice alike. Package API calls are recorded under the user package-api with the caller's address; backoffice calls under the signed-in user. Each entry carries the counts, the strategy, and the includeSecrets / overwriteEnvironmentFields values used; a refused import records the reason. Failed token checks go to the application log instead.

Imports aren't atomic. If one fails partway, check the destination, which may be partly updated, and run the import again.