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:
| Setting | Opens |
|---|---|
CampaignCanvas:PackageApi:ExportToken | Export only. Set it on the environment you export from (staging). |
CampaignCanvas:PackageApi:ImportToken | Import only. Set it on the environment you import into (live). |
CampaignCanvas:PackageApi:Token | Both. 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
| Status | Meaning |
|---|---|
| 200 | The export package, or the import result |
| 400 | Import 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. |
| 401 | Missing or wrong token for that endpoint. Counts toward the failed-attempt limit. |
| 409 | Strategy 2 found aliases that already exist; nothing was changed. conflicts lists them. |
| 429 | Too many failed attempts from this address. Retry-After gives the seconds left. Refused even with a correct token. |
| 503 | No 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
| Included | Not 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 anybotToken,signingSecret,apiKey,password,tokenorsecret. - Workflow destinations — the webhook, Slack, Teams and Discord
url, and theto,ccandbccof 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:
| Strategy | Backoffice option | Meaning |
|---|---|---|
0 Overwrite | Overwrite existing | Overwrite everything in the package that already exists (by alias); create missing aliases |
1 Add missing only | Only import missing | Skip aliases that already exist. The default when strategy is left out. |
2 Fail if anything conflicts | Fail if anything conflicts | Nothing 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 newer | API only | Only 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.