Migrating consent from another platform
Moving a customer off another marketing platform means bringing their subscription preferences with them, and bringing them accurately: who agreed, to what, and when they agreed it. This page is the end-to-end recipe — create the lists, bring the templates, write the preferences, read what happened, and run the whole thing again after the CRM sync catches up.
It assumes you already have a token. Getting started takes you from nothing to a first call, and Using the API without a browser is for a tool that runs unattended.
What you need before you start
| Step | Scope |
|---|---|
| Create subscription lists | subscription-lists:write |
| Import templates | email-templates:write |
| Write preferences | consent:write |
| Create or change contacts while you do it | contacts:write |
| See how far the CRM's inbound sync reaches | integrations:read |
Ask for all of them in the first sign-in: what an administrator approves is the set you held when you connected, and it is not widened afterwards (see Scopes). The write scopes also need the person who authorized the application to be an Admin, and to still be one when the migration runs — the role is checked against their current membership, not the one they had when they signed in. A migration is exactly the kind of job that runs weeks after the sign-in, so authorize it as a service user rather than as a person (Using the API without a browser explains why).
Three rules shape everything below. They are much easier to design around than to discover halfway through a run:
- A grant never overwrites a withdrawal, or a confirmation request that is still waiting. The write is reported and the row is left exactly as it was.
- An opt-out always applies, over anything, including a confirmed double opt-in.
- A suppression can be set through this API and never cleared through it. Sending
falsefordo_not_contact,do_not_contact_emailordo_not_contact_smsonPATCH /contacts/{contact_id}is refused with422 suppression_cannot_be_cleared, and afalsein a migration'sglobalsection is simply not applied — a flag that is set stays set, and you are told so withskipped_opt_out_preserved. Of everything on this page this is the rule most likely to surprise you, so plan for it: your source system saying somebody is contactable is a statement about the day the export was taken, and the person may have asked us to stop since. They change their own mind in the preference centre.
Step 1: create the lists
Every preference you write names a subscription list in this app, so the lists come first — one for each topic, purpose or marketing list the source system has.
Send a source_ref with each one. It is the record's identity in the system you are
migrating from, and it is what makes a second run find the list the first run made
instead of creating another:
curl -sS -X POST https://acme.example.com/api/public/v1/subscription-lists \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Monthly newsletter",
"description": "News and offers from Acme, once a month.",
"source_ref": { "system": "clickdimensions", "record_id": "topic-aaa" }
}'
{ "outcome": "created", "id": "6f1c3f2a-12b4-4e0a-9f6c-6d7f9f0a1b2c", "list": { "...": "..." } }
systemcomes from a fixed set:ms_customer_insights_journeys,clickdimensions,dynamics365,hubspotormailchimp. Case and surrounding space do not matter, and anything else is a422. The set is deliberately closed: two spellings of one system are two namespaces, so the same record would map twice and the second run would match neither mapping.record_idis that system's own id for the record, up to 255 characters.descriptionis required, and it is the consent copy shown next to the subscribe checkbox: write what subscribing to this list means, not a note to yourself. It is what makes the consent informed.- Keep the
idyou get back. It is whatlist_idtakes in step 3.
Give each list the opt-in model its source purpose had. Send
double_optin_required with each list, mapped from how the source system enforced
consent for that purpose. In Customer Insights – Journeys a Restrictive purpose,
where only people who opted in may be mailed, is true; a Non-Restrictive one,
where everyone may be mailed unless they opted out, is false, which is also the
default. Getting this wrong is not cosmetic. A list made with false treats everyone
who has not opted out as subscribed, so a Restrictive purpose migrated as one makes
every contact who never opted in mailable: consent the customer never gave. A list
made with true mails only the people you write a confirmed preference for in
step 3. The setting is taken only when the list is created. A re-run never
changes it, and the answer's list.double_optin_required is the setting the list
actually has, so compare it with what you sent and report a difference rather than
assume. true is honoured whether or not double opt-in has been switched on for the
account in the app. While it has not, the customer sees the list's double opt-in on in
Settings but cannot change it there, so tell them which lists you made that way.
Without a source_ref, a second run meets 409 subscription_list_name_in_use, and
renaming the list to get past it leaves the customer with two. With one, the second
run is answered 200 matched_by_source_ref with the list the first run made —
whatever it has since been renamed to, because the mapping is by source record
and not by name. Tidying up an imported list is exactly what importing is for.
Every outcome a source_ref can produce — including what happens when the list it
is mapped to has since been deleted, which is reported and not undone — is in
Re-running a migration. A mapping lasts 180
days from the last run that used it, and every run that recognises it pushes that
out, so a migration still being re-run never forgets.
Step 2: bring the templates across
Optional, and the same shape. POST /email-templates/import
takes an HTML document and a source_ref, and imports it as a draft the customer can
edit. The document is sanitized, its merge fields are mapped to this app's own and its
unsubscribe link is rewritten on the way in.
A second run with the same source_ref answers matched_by_source_ref and writes
nothing: by then the customer has very likely edited the draft, and that edit is
not a migration's to discard. Pass "overwrite": true when the source really is
authoritative and nobody has been editing.
Step 3: write the preferences
POST /contacts:bulk writes a contact's fields,
addresses and consent for up to 1,000 contacts in one request. More than that is
400 too_many_items and nothing is done, so page your export into batches of 1,000
or fewer. An Idempotency-Key header is required — one fresh value per batch, and
the same value again if you have to retry that batch.
curl -sS -X POST https://acme.example.com/api/public/v1/contacts:bulk \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0f8c2b14-5d3a-4e7f-9a21-6b0c4d8e1f32" \
-d '{
"default_provenance": {
"method": "migration",
"source_system": "clickdimensions"
},
"create_missing": false,
"items": [
{
"match": { "email": "jane@acme.example" },
"lists": [
{ "list_id": "6f1c3f2a-12b4-4e0a-9f6c-6d7f9f0a1b2c", "status": "confirmed",
"consented_at": "2024-03-01T10:04:22Z",
"consent_text_snapshot": "I agree to receive marketing emails from Acme.",
"source_record_id": "a1b2c3d4" }
]
},
{
"match": { "email": "sam@acme.example" },
"global": { "do_not_contact_email": true },
"lists": [
{ "list_id": "6f1c3f2a-12b4-4e0a-9f6c-6d7f9f0a1b2c", "status": "opted_out" }
]
}
]
}'
Four things decide whether the result is a faithful record:
default_provenanceis required, and itsmethodmust bemigration. That is the only provenance this API claims for what it writes, and it is what the consent record says afterwards, for a grant and for a withdrawal alike: migrated consent stays distinguishable from consent somebody gave you directly, forever, to anyone who asks where it came from.consented_atis required on everyconfirmedentry, and it is the time the person originally agreed. It is recorded as given and never defaulted to now: a migration that stamped today's date would turn a real consent record into a mass re-consent nobody performed. An entry without one, with one in the future, or with an implausibly old one — before1990-01-01T00:00:00Z— is reportedinvalidand written nowhere. That floor is above every placeholder a source writes when it has no date at all (0001-01-01,1753-01-01,1899-12-30,1900-01-01,1970-01-01), so send a record with one of those as its own outcome rather than letting it be recorded as the day somebody agreed.create_missingisfalseby default, and it decides whether an item whose match finds nobody creates the contact. Leave it off unless the customer has asked for it — this is the "create the contacts this migration cannot find" question a migration tool puts on its plan step, and unchecked means do not create. An item may carry its owncreate_missingto override the request's. Only anemailmatch can create, because the address is what makes the next run find the contact instead of making a second one, and it must be a real boolean: the string"false"is refused.- One bad item never costs you the batch. What is wrong with a value is reported
on its own item and the other 999 are applied. What is wrong with the structure of
the request — a field the route does not accept, a provenance that is not
migration, an over-long batch — is refused whole, with nothing written.
Nothing is sent and nothing is announced. No confirmation email goes out, no automation sees the contacts, and nothing is queued back to the CRM: these are the customer's own records moving in, and pushing them back out would loop.
For a single contact, PATCH /contacts/{contact_id}/subscriptions
does the same job under the same rules, and answers with the same result object a bulk
response holds for one item. The one difference is the match: the contact id in the path
is this app's own, so a contact that is not there is a 404 and not an outcome.
Step 4: read the answer
The response is a summary and one results entry per item, in the order you sent
them. An item's outcome is the worst of its sections', so scanning for
outcome != "applied" finds everything that needs a look without walking every
section. Every count is present whether or not it is zero, and
`items = contacts.matched + contacts.created + contacts.not_found + contacts.ambiguous
- invalid + forbidden_section`, so you can reconcile the run against your own totals rather than trusting it.
The full outcome vocabulary is published API: values are added and never renamed, so it is safe to switch on. These are the ones that ask something of you:
outcome | What to do |
|---|---|
contact_not_found | In scope, and not synced yet. Nobody matched and create_missing was off. Run the batch again after the CRM sync has brought the person in — see step 5. |
list_not_found | The list_id is not a live list. Create it (step 1) and run again. |
skipped_opt_out_preserved, skipped_pending_preserved | Working as intended: a withdrawal or a waiting confirmation was on file and the grant was not written. Nothing to fix; report it so the customer can see the difference between what the source said and what is recorded. |
address_owned_by_other_contact | Another contact holds that address, and it was left there. The API never moves an address, because an address carries its whole consent history. A person has to decide. |
ambiguous_match | An external id matched several contacts and the write was a grant or a creation. Resolve the duplicate at the source. |
invalid | Something in the item is wrong. reason is a stable code and detail a sentence for a person. |
forbidden_section | The item carried a section this credential's scopes do not cover — a credential with only consent:write can record preferences and cannot invent people. Ask for the missing scope and run again. |
address_erased (a reason) | Somebody exercised their right to erasure. This is an expected exclusion, not a fault: no sync and no re-run will change it. Skip the record. |
contact_not_found here is an item outcome and means "nobody matched yet". The
route-level 404 contact_not_found is a different answer with the same words: it means
the contact id in the path is not one this account has. See
Conventions.
To check what was recorded for a particular person, read it back with
GET /contacts/{contact_id}/subscriptions:
it shows each address against each active list, with method, confirmed_at and what
the list actually does with the address.
Step 5: run it again
Assume you will. You hit a problem and re-run, or you re-run after a CRM sync to pick up the people who were not in the app the first time. The whole recipe is built for it:
- A consent write that changes nothing is
already_in_state, and adds no row to the contact's consent history. A clean re-run isalready_in_statethroughout. - A list or template you sent a
source_reffor is matched, not duplicated, and the match pushes its 180-day window out again. - An address that already exists is not added twice, and no second contact is made for it.
- An
Idempotency-Keyprotects a retry of one request for seven days. It is not what makes a later re-run safe; the rules above are. Use a fresh key for a genuinely new run.
Deciding what is actually missing is your job, not ours. A contact_not_found
means the person was not in the app when the batch ran — but a CRM's inbound sync can
be narrowed to a set of marketing lists or to records carrying a flag, and a record
outside that scope is never fetched and is legitimately absent. Read
GET /integrations for how far the sync reaches,
exclude the records that are deliberately outside it, and report the rest as the gap.
A migration that cannot see the scope reports every out-of-scope record as a problem,
and the customer cannot tell the real gaps from the noise. If scope_stale is true,
a re-sync that would close some of the gap has not finished yet; say so, and let the
customer decide when to re-run.
Related guides
- Route reference: every route, field and outcome in full.
- Conventions: errors, idempotency, pagination and rate limits.
- Scopes: the nine scopes, and who can delegate what.
- Getting started: from nothing to a first call.