Free live webinar

Same team, more pipeline: AI-powered marketing for Dynamics 365 · Wed 11 Nov · 1:00pm ET · with Microsoft MVP Mark SmithSame team, more pipeline · 11 Nov

Save my seat

Migrating consent from another platform

Developer

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

StepScope
Create subscription listssubscription-lists:write
Import templatesemail-templates:write
Write preferencesconsent:write
Create or change contacts while you do itcontacts:write
See how far the CRM's inbound sync reachesintegrations: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 false for do_not_contact, do_not_contact_email or do_not_contact_sms on PATCH /contacts/{contact_id} is refused with 422 suppression_cannot_be_cleared, and a false in a migration's global section is simply not applied — a flag that is set stays set, and you are told so with skipped_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": { "...": "..." } }
  • system comes from a fixed set: ms_customer_insights_journeys, clickdimensions, dynamics365, hubspot or mailchimp. Case and surrounding space do not matter, and anything else is a 422. 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_id is that system's own id for the record, up to 255 characters.
  • description is 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 id you get back. It is what list_id takes 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_provenance is required, and its method must be migration. 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_at is required on every confirmed entry, 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 — before 1990-01-01T00:00:00Z — is reported invalid and 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_missing is false by 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 own create_missing to override the request's. Only an email match 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:

outcomeWhat to do
contact_not_foundIn 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_foundThe list_id is not a live list. Create it (step 1) and run again.
skipped_opt_out_preserved, skipped_pending_preservedWorking 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_contactAnother 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_matchAn external id matched several contacts and the write was a grant or a creation. Resolve the duplicate at the source.
invalidSomething in the item is wrong. reason is a stable code and detail a sentence for a person.
forbidden_sectionThe 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 is already_in_state throughout.
  • A list or template you sent a source_ref for 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-Key protects 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.