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

Route reference

Developer

The routes in this release. Paths are relative to the base URL, https://<tenant-host>/api/public/v1, except the metadata document, which is served from the host's root (the reason is under its entry below).

Method and pathNeedsWhat it is for
GET /.well-known/oauth-protected-resource/api/public/v1NothingWhere to sign in, and what to ask for.
GET /metaAn application the organization has let in; no particular scopeWhich tenant and which credential the API believes you are.
GET /integrationsintegrations:readThe connected integrations, and how far inbound sync reaches.
GET /contactscontacts:readA page of contacts, oldest change first; find one by address or external id.
GET /contacts/{contact_id}contacts:readOne contact.
POST /contactscontacts:writeCreate one contact.
PATCH /contacts/{contact_id}contacts:writeChange a contact's fields.
POST /contacts/{contact_id}/emailscontacts:writeAdd an address to a contact.
GET /contacts/{contact_id}/subscriptionsconsent:readWhich lists a contact is on, and where that came from.
PATCH /contacts/{contact_id}/subscriptionsconsent:writeRecord a contact's opt-ins and opt-outs, leaving every list not named alone.
POST /contacts:bulkcontacts:write, consent:write, or bothWrite up to 1,000 contacts' fields, addresses and consent in one request.
GET /subscription-listssubscription-lists:readEvery list that has not been deleted.
POST /subscription-listssubscription-lists:writeCreate one list, re-run-safely.
GET /email-templatesemail-templates:readA page of templates, newest first.
POST /email-templates/importemail-templates:writeImport one HTML template, re-run-safely.

Every response carries X-Tstr-Api-Version. Every error has the shape described in Conventions. Every route that changes data takes an Idempotency-Key: optional on all of them except POST /contacts:bulk, where it is required. GET /contacts and GET /email-templates are paged. The rest return one record or a short, complete list.

The two routes that create — POST /subscription-lists and POST /email-templates/import — take an optional source_ref that makes them safe to run again: see Re-running a migration.

The scopes are explained in Scopes, and the sign-in in Authentication.

GET /.well-known/oauth-protected-resource/api/public/v1

The API's protected-resource metadata (RFC 9728). No token is needed: a client reads it to learn where to get one.

The path is not under /api/public/v1. The standard inserts the well-known segment between the host and the path of the resource it describes, so a resource at /api/public/v1 is described at /.well-known/oauth-protected-resource/api/public/v1. Appending the segment to the base URL instead gives a 404.

{
  "resource": "https://acme.example.com/api/public/v1",
  "authorization_servers": ["https://clerk.example.com"],
  "scopes_supported": [
    "contacts:read", "contacts:write", "consent:read", "consent:write",
    "subscription-lists:read", "subscription-lists:write",
    "email-templates:read", "email-templates:write", "integrations:read",
    "user:org:read"
  ],
  "bearer_methods_supported": ["header"]
}
FieldMeaning
resourceThe API's canonical address. Send it as resource when you request a token, exactly as written; it becomes the token's audience.
authorization_serversThe issuers that issue tokens for this API. Fetch an issuer's own metadata to find its endpoints.
scopes_supportedThe nine scopes, and user:org:read, which is not one of ours but is what puts the organization selector on the consent screen. A client with nothing more specific to go on asks for all of it.
bearer_methods_supportedheader: a token is sent in the Authorization header and nowhere else.

If this deployment has not been set up to serve the API, the answer is 503 with type: public_api_not_configured, and not a document with an empty resource.

GET /meta

What this deployment is, and what this credential may do. Any application the organization has let in can call it, whatever its scopes: the point is that the caller who most needs to know what it can do is the one that can do the least.

A token for the wrong organization never gets this far (it is refused with tenant_mismatch), so a 200 here is itself the confirmation that you are talking to the tenant you meant to.

{
  "api_version": "v1",
  "tenant_id": "tnt_0123456789abcdef",
  "scopes": ["consent:read", "integrations:read"],
  "client": {
    "id": "https://migrate.example.org/client.json",
    "name": "https://migrate.example.org/client.json"
  },
  "server_time": "2026-10-16T09:30:00.123456+00:00"
}
FieldMeaning
api_versionv1. The same value as the X-Tstr-Api-Version header.
tenant_idIdentifies the tenant. See below.
scopesThe scopes this credential may use right now, sorted. See below.
client.idThe OAuth client the token was issued to: for a client identified by a metadata document, that document's URL.
client.nameA label for the application. Today it is the same string as client.id; do not build on the two differing.
server_timeThe server's current time, ISO 8601 in UTC. Useful for spotting clock skew.

tenant_id is an opaque string, and its kind depends on the deployment. When the deployment has a platform tenant identifier it begins tnt_. When it does not, it is the organization's own identifier and begins org_. Compare it for equality and nothing more: do not parse it, do not assume a prefix, and do not use it as a key you expect to be the same on every deployment. If you record it to remember which tenant you confirmed, record it together with the base URL, and treat a change as a reason to re-confirm with the customer, not as proof that something is wrong.

scopes is the effective set, not the set you asked for and not the set on your token. It is the scopes the administrator approved the application for, less any write scope the person who signed in may not delegate. So a client that compares it with its request will see fewer entries when the application is capped or the signer is not an Admin, and it will not see the sign-in scopes it also sent, such as openid, offline_access or user:org:read: the list is made of the nine scopes and nothing else. Read it as the answer to "what will this credential be allowed to do", and compare it with what your application needs before it starts.

Errors: 401 (authentication_required, invalid_token, token_audience_mismatch), 403 (tenant_mismatch and the four governance types), and 503 (authorization_unavailable, public_api_not_configured). See Conventions.

GET /integrations

The integrations connected to this account, and for a CRM, how far inbound sync reaches. Needs integrations:read.

Inbound CRM sync can be narrowed to a set of marketing lists, or to records with a flag set, and a record outside that scope is never fetched, so it is legitimately absent from the app. A migration that cannot see the scope would report every such record as a gap. This route is how it sees.

{
  "connections": [
    {
      "category": "crm",
      "provider": "dynamics365",
      "status": "connected",
      "scope_mechanism": "list",
      "inbound_scope_list_ids": ["list-aaa", "list-bbb"],
      "inbound_scope_field_flag": false,
      "scope_stale": true
    }
  ]
}
FieldMeaning
categoryThe kind of integration. crm for a CRM.
providerThe provider: dynamics365, hubspot or pipedrive for a CRM.
statusconnecting, connected, error, disconnected or mapping_pending. More may be added.
scope_mechanismHow inbound sync can be narrowed: list (to members of a set of marketing lists), field_flag (to records with a flag field set) or none (it cannot be narrowed).
inbound_scope_list_idsWith list, the ids of the lists sync is limited to, sorted. Empty when there is no limit, and for every other mechanism.
inbound_scope_field_flagWith field_flag, whether sync is limited to flagged records. false otherwise.
scope_staletrue when the scope was widened and the re-sync that fetches the newly in-scope records has not completed.

Connections are ordered by category, provider and id. Credentials, status detail and each connection's settings are never part of the response.

Do not read this list as more than it is. Four things to know before a migration builds a precondition on it:

  • It lists connections in any status, not only connected ones. Check status before treating a connection as usable.
  • It comes from the server's in-memory copy, loaded when the server starts and updated by changes made through that same server. If the account runs on several instances, a change made through another instance is not visible here until this one restarts.
  • An empty list is not proof that nothing is connected. If loading the connections failed at startup, the failure is logged and the server carries on with an empty copy, and that answers "connections": [] just as an account with no integrations does. The two cannot be told apart from outside.
  • Only some integrations are listed. Messaging and webinar connections are managed elsewhere and are not included.

With scope_stale: true, counts taken now would be changed by the re-sync that is still to run. A migration should say so, and let the customer decide, instead of reporting a gap that a pending sync would close.

Errors: as for GET /meta, plus 403 insufficient_scope when the credential does not carry integrations:read.

GET /contacts

A page of contacts. Needs contacts:read.

The order is oldest change first: by when a contact last changed, and then by id, so that two contacts changed in the same instant (which a bulk write does) always fall in the same order. That is what makes a walk over every contact safe while they change. There is no offset: send each page's next_cursor back as cursor. A contact you have already read that changes before you reach the end comes round again further on, and one created while you walk is still ahead of you, so dedupe by id and treat the last copy you saw as the current one. Archived contacts are listed, with status: archived: they still hold their email addresses, which are unique across the account, and a client that could not see them could not tell why a create was refused.

Query parameterMeaning
emailOnly the contact that holds this address, whichever of its addresses it is. Case does not matter.
provider and external_idOnly contacts linked to this id at this CRM provider, for example dynamics365. Send both or neither: an external id means something only within its provider. Several contacts can hold the same one.
updated_sinceOnly contacts that changed at or after this instant, ISO 8601. A time with no offset is UTC.
limitThe page size, 1 to 500. Default 100.
cursorThe next_cursor of the page before. Opaque: see Pagination.
{
  "items": [ { "id": "7b1d4f0e9c2a4d5e8a31b6c0f2e9d714", "...": "see GET /contacts/{contact_id}" } ],
  "next_cursor": "eyJ2IjogWyIyMDI2LTEwLTE2VDA5OjMwOjAwLjEyMzQ1NiswMDowMCJdLCAiaWQiOiAiNyJ9",
  "count": 100
}

Errors: 422 for provider without external_id or the reverse, a cursor this API did not send, or a limit outside 1 to 500; and 403 insufficient_scope. See Conventions.

GET /contacts/{contact_id}

One contact. Needs contacts:read. A contact that does not exist is 404 contact_not_found.

{
  "id": "7b1d4f0e9c2a4d5e8a31b6c0f2e9d714",
  "status": "active",
  "first_name": "Jane",
  "last_name": "Doe",
  "primary_email": "jane@acme.example",
  "emails": [
    { "id": "a1c0...", "address": "jane@acme.example", "label": "work", "is_primary": true },
    { "id": "b7e2...", "address": "jane.doe@home.example", "label": null, "is_primary": false }
  ],
  "tags": ["customer"],
  "custom_fields": { "member_no": "M-1042" },
  "do_not_contact": false,
  "do_not_contact_email": false,
  "do_not_contact_sms": false,
  "salutation": null,
  "birthday": "1985-03-12",
  "company": "Acme Ltd",
  "job_title": null,
  "department": null,
  "website": null,
  "linkedin_url": null,
  "address_line1": null,
  "address_line2": null,
  "city": "Leeds",
  "state": null,
  "postal_code": null,
  "country": "GB",
  "timezone": "Europe/London",
  "lifecycle_stage": "customer",
  "lead_source": null,
  "preferred_channel": null,
  "created_at": "2026-03-01T10:04:22.123456+00:00",
  "updated_at": "2026-10-16T09:30:00.123456+00:00"
}

Every field is always there, and is null when nothing is set (tags is [] and custom_fields is {}). Do not rely on a field being absent.

FieldMeaning
idThe contact's id. Opaque: compare it for equality.
statusactive or archived. Archiving retires a contact and deletes nothing.
emailsEvery address the contact holds, the primary first. id is the address's own id. label is null when there is none.
primary_emailThe primary address, or null for a contact that has none (one the CRM knows only by phone).
tagsThe contact's tags.
custom_fieldsThe account's own contact fields, by key. A field that was never set is absent from the object.
do_not_contactNever contact on any channel.
do_not_contact_emailThe contact's global email opt-out: a stated preference, kept apart from an address's deliverability. A bounce or a complaint suppresses an address and does not set this. It stops every list's email to the contact whatever the lists say, and the per-list preferences are read from GET /contacts/{contact_id}/subscriptions.
do_not_contact_smsNever contact by SMS.
salutation, lifecycle_stage, lead_source, preferred_channelThe key of an option of that field, as the account defines its options.
birthdayISO 8601 date.
countryA two-letter ISO country code.
timezoneAn IANA time zone name.
created_at, updated_atISO 8601 in UTC.

Phone numbers, the contact's owner and the deprecated attributes mirror are not part of this API. The rest of the profile (company, job_title, department, website, linkedin_url, the address lines, city, state and postal_code) is text.

POST /contacts

Create one contact. Needs contacts:write. Answers 201 with the contact, in the shape above.

{
  "emails": [
    { "address": "jane@acme.example", "label": "work", "is_primary": true },
    { "address": "jane.doe@home.example" }
  ],
  "first_name": "Jane",
  "last_name": "Doe",
  "country": "United Kingdom",
  "custom_fields": { "member_no": "M-1042" }
}
FieldMeaning
emailsRequired, 1 to 10. Each has an address, an optional label of up to 64 characters, and is_primary. At most one is primary; if none is, the first is. The same address cannot be listed twice. An address must be a valid one, as the app's own contact form judges it: one on a special-use domain such as .test is refused. The bulk route applies the same rule to any address it is asked to store, so an address this route refuses is refused there too.
first_name, last_name, salutation, birthday, company, job_title, department, website, linkedin_url, address_line1, address_line2, city, state, postal_code, country, timezone, lifecycle_stage, lead_source, preferred_channelOptional text, held to the width of the field. The values are coerced as the app's own contacts API coerces them, so a country written as a name is stored as its code, and an option key that the account does not have is refused.
custom_fieldsOptional. The account's own fields, by key, each a text, a number or true/false. Only fields the account has defined; a standard field named here is refused.
do_not_contact, do_not_contact_email, do_not_contact_smsOptional booleans, false by default.

Anything else is refused whole with 422, and nothing is written. That includes phone numbers, owner_user_id, tags, status and a field this page does not list. A key that was named and quietly dropped would leave its sender believing it was applied.

What it does that you should know:

  • A contact made here is announced. It is announced like any other contact the app makes: automations whose trigger is a new contact will see it, with a source of api, and the contact is queued for the CRM. That is what contacts:write means. The bulk route is deliberately silent about the contacts it makes, because it moves a customer's own CRM data in and must not enrol hundreds of people in a journey; this one is for creating a contact on purpose.
  • The credential is recorded as the writer (the application's client id, cut to 64 characters with a trailing … if it is longer), and the contact's history in the app shows who created it.
  • An address that is not free is never taken or moved. An address another contact holds is 409 email_address_in_use. An address that belongs to somebody who exercised the right to erasure is 409 address_erased.

Idempotency-Key is optional. A retry with the same key and body is answered 201 with the same contact and makes no second one.

Errors: 409 (email_address_in_use, address_erased, and the two idempotency types), 422, 400 for an unusable Idempotency-Key, and 403 insufficient_scope.

PATCH /contacts/{contact_id}

Change a contact's fields. Needs contacts:write. Answers 200 with the contact as it is now. A contact that does not exist is 404 contact_not_found.

The body is a merge. A field you leave out is untouched. A field you send is written, and a field you send as null is emptied. The fields are the ones POST /contacts takes, minus emails (an address has its own route), plus status:

{ "do_not_contact_email": true, "company": "New Co", "last_name": null,
  "custom_fields": { "member_no": null, "favourite_colour": "blue" } }
  • custom_fields is merged into the contact's own, key by key: a value is set, a null or blank removes that key, and every other key stays.
  • status is active or archived, and null leaves it as it is.
  • do_not_contact, do_not_contact_email and do_not_contact_sms take true, and null or leaving the field out leaves the flag as it is. false is refused with 422 suppression_cannot_be_cleared, and the whole request is refused with it: a suppression is set through the API and never cleared through it. Undoing one makes somebody contactable again, and the copy you hold cannot know whether they have asked us to stop since it was taken. The person changes their own mind in the preference centre, and an administrator can in the app. This is the same rule the consent routes keep, where a migration's false is never applied either.
  • Anything the body does not list is refused whole with 422, as for POST /contacts. Addresses are changed through POST /contacts/{contact_id}/emails.
  • An empty body, {}, changes nothing and announces nothing.

A change is announced as the app announces any contact change (contact.updated, with a source of api, and the contact queued for the CRM), is recorded in the contact's history with the previous values, and sets the writer as for POST /contacts.

Idempotency-Key is optional. Errors: 404 contact_not_found, 422 (the schema, or suppression_cannot_be_cleared), 400 and the two idempotency 409s, and 403 insufficient_scope.

POST /contacts/{contact_id}/emails

Add an address to a contact. Needs contacts:write. Answers 201 with the address:

{ "address": "jane.doe@home.example", "label": "home", "is_primary": false }

address is required. label is up to 64 characters. is_primary: true makes this the contact's primary address and demotes the old one. Otherwise the address is added as a secondary, unless the contact has no address at all, when it is the primary whatever you send. The response is { "id", "address", "label", "is_primary" }.

An address that any contact holds, this one included, is 409 email_address_in_use: the API never moves an address, because it carries its consent history with it, and adding one the contact already has is a mistake worth knowing about. An address that belongs to somebody who asked to be erased is 409 address_erased. A contact that does not exist is 404 contact_not_found.

Like any other change to a contact, it is announced: the contact is queued for the CRM, and when the new address becomes the primary, contact.updated is emitted for the email field with a source of api.

Idempotency-Key is optional. A retry is answered 201 with the same address, and does not answer 409 for the address it has itself just added.

GET /contacts/{contact_id}/subscriptions

A contact's consent: what is recorded for each of its addresses and each active list, and where it came from. Needs consent:read. A contact that does not exist is 404 contact_not_found: that and a contact with no addresses are opposite answers, and the second is 200 with subscriptions: [].

{
  "contact_id": "7b1d4f0e9c2a4d5e8a31b6c0f2e9d714",
  "subscriptions": [
    {
      "address": "jane@acme.example",
      "is_primary": true,
      "address_suppressed": false,
      "subscription_list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2",
      "list_name": "Monthly newsletter",
      "double_optin_required": false,
      "status": "confirmed",
      "method": "migration",
      "method_detail": "migrated from ms_customer_insights_journeys; written by https://migrate.example.org/client.json",
      "double_optin": false,
      "effective": "subscribed_unverified",
      "is_subscribed": true,
      "requested_at": null,
      "confirmed_at": "2024-03-01T10:04:22+00:00",
      "opted_out_at": null
    }
  ]
}

There is one row for each address and each active list, including the pairs nothing has ever been recorded for, because a reader that listed only recorded rows could not tell "not subscribed" from "not shown".

FieldMeaning
statusWhat is recorded: confirmed, pending, opted_out or expired. null means nothing is recorded, which is not the same as opted out: on a list that does not ask for confirmation, a pair with nothing recorded is mailed. Read effective.
method, method_detailHow and from where it was recorded. Consent written by POST /contacts:bulk or the route below has method migration, and method_detail names the source system and the credential that wrote it. null with no record.
double_optinWhether a confirmation was ever sent for the record. false for migrated consent, which is a literal fact: this API never sends one. null with no record.
confirmed_atWhen the person confirmed or originally agreed. For migration consent it is the original time, not the time it was imported.
opted_out_atWhen the withdrawal was recorded here.
effectiveWhat the list does with this address, derived once for every caller: subscribed_implicit, subscribed_unverified, subscribed_confirmed, awaiting_confirmation, confirmation_expired, unsubscribed or not_subscribed. More may be added.
is_subscribedWhether the address is mailed for this list. It is about this list's consent and nothing else: a global opt-out (do_not_contact_email), a suppressed address and an erasure each stop mail on their own.
address_suppressedThe address is suppressed for deliverability (a bounce or a complaint).

PATCH /contacts/{contact_id}/subscriptions

Record a contact's opt-ins and opt-outs. Needs consent:write. A merge, not a replacement: the lists you name are applied and every list you do not name is untouched, because an absent list must never mean a withdrawal. There is no PUT.

{
  "provenance": {
    "method": "migration",
    "source_system": "ms_customer_insights_journeys",
    "method_detail": "purpose 'Commercial Communication' / topic 'Newsletter'"
  },
  "lists": [
    { "list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "confirmed",
      "consented_at": "2024-03-01T10:04:22Z",
      "consent_text_snapshot": "I agree to receive marketing emails from Acme.",
      "source_record_id": "a1b2c3d4" },
    { "list_id": "9c218a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "opted_out" }
  ],
  "address": "jane@acme.example"
}
FieldMeaning
provenanceRequired. method must be migration, the only provenance this API claims for what it writes, and source_system must name where the preferences came from. method_detail is optional free text. Anything else is refused whole with 422.
listsRequired, 1 to 50. See the entry below.
addressOptional. Narrows which of the contact's addresses the preferences apply to. Without it an opt-out applies to all the contact's addresses and a grant to the primary one. An address the contact does not hold is reported as address_not_found and nothing is recorded.

An entry in lists:

FieldMeaning
list_idThe subscription list. One that does not exist or has been deleted is reported as list_not_found.
statusconfirmed (opted in) or opted_out. pending and expired describe live double opt-in requests and cannot be asserted from outside: they are reported as invalid.
consented_atRequired for confirmed: when the person originally agreed, ISO 8601 with an offset. It is recorded as given and never defaulted to now: a confirmed entry without one, with one in the future, or with an unparseable or implausibly old one is invalid, because a false date would make an address mailable. Implausibly old means before 1990-01-01T00:00:00Z, which is above every placeholder a source writes when it has no date (0001-01-01, 1753-01-01, 1899-12-30, 1900-01-01, 1970-01-01) and below any consent a marketing platform could have recorded. A value equal to it is accepted.
opted_out_atAccepted and not stored. A withdrawal is recorded at the moment it is received.
consent_text_snapshotThe wording the person agreed to, kept with a confirmed entry.
method_detail, source_record_idKept with the entry; override the request's method_detail for this entry.

The promises this makes, because they are what a regulator is told:

  • A grant never overwrites a withdrawal, or a request that is waiting for confirmation. It is reported skipped_opt_out_preserved or skipped_pending_preserved, and the row is exactly as it was.
  • An opt-out always applies, over anything, a confirmed double opt-in included, and leaves the grant's own provenance as it was.
  • A grant is recorded with method: migration, double_optin: false and the original confirmed_at. Every change that happened appends one row to the contact's consent history; a repeat that changes nothing appends none, and says already_in_state.
  • Nothing is ever sent. No confirmation email goes out.

The answer is 200 and the contact's result, the same object a bulk response holds for one item: outcome (the worst of the entries'), contact_id, and one result in lists for each list in the order sent. A skipped grant, a list that does not exist and an address the contact does not hold are answers and not failures, so read outcome and the entries.

{
  "index": 0,
  "match": { "contact_id": "7b1d4f0e9c2a4d5e8a31b6c0f2e9d714" },
  "outcome": "applied",
  "contact": "matched",
  "contact_id": "7b1d4f0e9c2a4d5e8a31b6c0f2e9d714",
  "lists": [
    { "list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "confirmed", "outcome": "applied" },
    { "list_id": "9c218a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "opted_out", "outcome": "already_in_state" }
  ]
}

A contact that does not exist is 404 contact_not_found: the id in the path is the app's own, so unlike a bulk item this is not "not synced yet".

Idempotency-Key is optional, and a retry is replayed. The key belongs to the contact's URL, so the same key and body sent for two contacts are two requests, and neither swallows the other's opt-out.

Errors: 404 contact_not_found, 422, 400, the idempotency 409s, and 403 insufficient_scope.

POST /contacts:bulk

Write a contact's fields, addresses and consent for up to 1,000 contacts in one request, and be told what became of each. It is built for moving a customer's data in from another platform: a re-run is safe, every item gets an answer, and one bad item does not cost you the other 999. Needs contacts:write, consent:write, or both. Idempotency-Key is required. The order to do a whole migration in is Migrating consent from another platform.

{
  "default_provenance": { "method": "migration", "source_system": "ms_customer_insights_journeys" },
  "create_missing": false,
  "items": [
    {
      "match": { "email": "jane@acme.example" },
      "global": { "do_not_contact_email": true },
      "lists": [ { "list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "opted_out" } ]
    },
    {
      "match": { "provider": "dynamics365", "external_id": "a1b2c3d4" },
      "create_missing": true,
      "contact": { "first_name": "Sam", "last_name": "Okafor", "tags": ["migrated"] },
      "emails": [ { "address": "sam@acme.example", "is_primary": true, "label": "work" } ],
      "lists": [ { "list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "confirmed",
                   "consented_at": "2024-03-01T10:04:22Z" } ]
    }
  ]
}

The request.

FieldMeaning
default_provenanceRequired. As provenance above: method is migration, source_system names the source, method_detail is optional. Refused whole if wrong, because it decides what the audit trail says and is the same for every item.
create_missingfalse by default. Whether an item whose match finds nobody creates the contact. An item may carry its own. Only an email match can create: the address is what makes a re-run find the contact again. It must be a real boolean: the string "false" is refused.
itemsRequired, at most 1,000. More is 400 too_many_items and nothing is done. An empty list is fine.

An item has match and up to four sections, each needing the scope that covers it:

Item keyNeedsWhat it does
matchWho the item is about: exactly one of {email}, {contact_id} or {provider, external_id}, optionally with address to narrow which of the contact's addresses a preference applies to. An email is matched as strip().lower(), as everywhere else.
contact, emails, create_missingcontacts:writecontact writes the contact's fields (the same ones POST /contacts takes, with tags besides, and no emails, status or flags): a merge, where tags are added and never removed and a blank never clears a value. emails adds the addresses the contact does not have, never removing or moving one and never changing which is primary. An address this route would have to CREATE — the one a create_missing match holds, and an emails entry naming an address the account does not have — must be valid by the same rule POST /contacts applies; one that is not is invalid and nothing is written for it. An address the account already has is not judged by that rule at all, however it was once written: it is found, and reported as this contact's own or as address_owned_by_other_contact.
globalconsent:write{ "do_not_contact_email": true } sets the contact's global email opt-out. false does not clear one that is set: a migration never undoes a withdrawal.
listsconsent:writePer-list preferences, as lists above.
method_detail, source_record_idKept with the item's consent records.

An item that carries a section the credential's scopes do not cover is forbidden_section and nothing is written for it: a credential that holds only consent:write can record preferences and cannot invent people. Scopes are per item, so a call is refused whole with 403 insufficient_scope only when the credential holds neither write scope.

Limits. Published, so a client pages deliberately.

LimitValueWhen it is exceeded
Items in a request1,000400 too_many_items, nothing done
lists entries in an item50422, nothing done
emails entries in an item10422, nothing done

What is refused whole and what is reported per item. The structure of the request is checked before anything is written: a field this page does not list, a section of the wrong kind (a list where an object goes, a number where text goes), a provenance that is not migration, a create_missing that is not a boolean, and the limits above are all 422 or 400, with nothing written. A phone number is not accepted anywhere in this API: it is a contact-matching key with two ways of being read already, and a third would make two contacts of one person. What is wrong with a value is reported on its item, and the rest are applied: a list that does not exist, a consent date in the future, an address that is not one, a name longer than the field, an option the account does not have. The account's own contact fields go in contact.custom_fields.

What it never does, which are the promises that make a re-run safe:

  • A grant never overwrites a withdrawal or a request awaiting confirmation, and says so (skipped_opt_out_preserved, skipped_pending_preserved). An opt-out always applies. An opt-out for several matched contacts applies to all of them; a grant for several is refused (ambiguous_match).
  • It never creates a contact unless you ask, and when it does it makes no second one: re-running creates nothing. A contact is never created for an address its owner erased (address_erased), and never when the match is ambiguous.
  • It never moves an address. An address another contact holds is reported (address_owned_by_other_contact) and left where it is.
  • It says nothing to anyone about what it writes. It announces no contact to any automation and queues nothing for the CRM: these are your customer's own records moving in, and pushing them back out would loop.
  • Every consent change adds one row to the contact's consent history, with a source of migration, and the preference itself is recorded with method: migration and your source_system. A re-run that changes nothing adds no row.
  • It never duplicates what an earlier run did. Send the same batch again and nothing is written twice: an address that is already there is not added again, a contact is not made a second time (addresses are unique across an account, which is what lets a re-run find the person), and consent already in the state you are asking for is already_in_state. That is a property of the data and not of the Idempotency-Key, which protects a retry of one request for seven days and is a different thing from running the migration again next week. The two writes that are not re-run-safe by nature — creating a subscription list and importing a template — are made so by a source_ref: see Re-running a migration.

The response.

{
  "summary": {
    "items": 2,
    "contacts": { "matched": 1, "created": 1, "updated": 0, "not_found": 0, "ambiguous": 0 },
    "addresses": { "added": 0, "owned_by_other_contact": 0 },
    "consent": { "applied": 2, "already_in_state": 0, "skipped_opt_out_preserved": 0,
                 "skipped_pending_preserved": 0, "list_not_found": 0, "contact_not_found": 0,
                 "address_not_found": 0, "ambiguous_match": 0, "invalid": 0 },
    "global": { "applied": 1, "already_in_state": 0, "skipped_opt_out_preserved": 0,
                "contact_not_found": 0, "address_not_found": 0, "ambiguous_match": 0, "invalid": 0 },
    "invalid": 0,
    "forbidden_section": 0
  },
  "results": [
    { "index": 0, "match": { "email": "jane@acme.example" }, "outcome": "applied",
      "contact": "matched", "contact_id": "7b1d4f0e9c2a4d5e8a31b6c0f2e9d714",
      "lists": [ { "list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "opted_out",
                   "outcome": "applied" } ],
      "global": { "do_not_contact_email": "applied" } },
    { "index": 1, "match": { "provider": "dynamics365", "external_id": "a1b2c3d4" },
      "outcome": "applied", "contact": "created", "contact_id": "c93f0e8a31b64f2e9d7147b1d4f0e9c2",
      "emails": [ { "address": "sam@acme.example", "outcome": "address_added" } ],
      "lists": [ { "list_id": "4f0e8a31-b6c0-4f2e-9d71-47b1d4f0e9c2", "status": "confirmed",
                   "outcome": "applied" } ] }
  ]
}

results has one entry per item in the order sent, whatever happened to it. 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. summary counts every item in exactly one of six ways (items = contacts.matched + contacts.created + contacts.not_found + contacts.ambiguous + invalid + forbidden_section), and counts every list entry and every global section under its outcome, every outcome a key, zero included, so you can reconcile it against your own totals. contacts.updated counts the matched items whose contact fields were written, and is part of matched.

An item's contact says what its match came to: matched, created, not_found or ambiguous. Only the sections an item had are in its result, as keys that are left out and never sent as null. The result's match echoes what you sent, cleaned of anything that cannot be echoed.

The outcomes, best to worst. This is the vocabulary an attestation repeats, and it is published API: values are added and never renamed.

outcomeMeaning
already_in_stateNothing to write: the state is what you asked for. A re-run is this throughout.
address_addedAn address entry: it was absent and has been added. For the item's own outcome it counts as applied.
appliedState written: a grant recorded, a withdrawal recorded, a flag set, a contact made or changed.
skipped_pending_preservedA grant that was not applied: a request awaiting confirmation (or one that expired unanswered) is on file.
skipped_opt_out_preservedA grant that was not applied: a withdrawal is on file.
list_not_foundThe list is not there. Create it, then run again.
contact_not_foundNobody matched, and create_missing was off. It means in scope, and not synced yet: run again after the CRM sync. Excluding records that are deliberately outside the CRM's inbound sync is the caller's job; see GET /integrations.
address_not_foundThe contact matched and the address you named is not on it. A withdrawal for it is not recorded, because the source's own record is inconsistent and a person has to look.
address_owned_by_other_contactAn address entry: another contact holds it, and it was left there.
ambiguous_matchAn external id matched several contacts, and the write was a grant or a creation.
invalidSomething in the item is wrong; reason is a stable code and detail a sentence.
forbidden_sectionThe item has a section the credential's scopes do not cover; sections names them.

reason, where the outcome alone is too coarse, is a code to switch on. The ones an item can carry: item_malformed, nothing_to_apply, match_malformed, entry_malformed, global_malformed, status_not_assertable, consented_at_required, consented_at_unparseable, consented_at_in_future, consented_at_implausibly_old, text_unstorable, contact_malformed, emails_malformed, create_needs_email_match, contact_create_failed, address_erased, existing_request_expired and primary_not_applied. address_erased is an expected exclusion and not something to fix: nothing a sync does will change it.

Retrying. Send each batch under a key of its own and, if a request times out, send the same request again under the same key: if the first finished you are given its answer, and if it is still running you are told (409 idempotency_request_in_progress, with Retry-After) and send it again. A different body under a used key is 409 idempotency_key_reused. The consent writes are safe to repeat on their own, and the key is what stops the repeat from adding a second row to the consent history. See Idempotency.

A request of up to 1,000 items is applied in one piece: if the server fails in the middle, none of it happens, and you repeat it under the same key.

Errors: 400 (too_many_items, idempotency_key_required, invalid_idempotency_key), 409 (the two idempotency types), 422, and 403 insufficient_scope when the credential holds neither write scope. See Conventions.

Re-running a migration

Assume you will run it more than once. You hit a problem and run it again, or you run it again after a CRM sync to pick up what was pending. Contacts and consent are already safe to repeat: addresses are unique across an account, a consent write that changes nothing reports already_in_state, and an Idempotency-Key protects a retry of one request.

Creating a subscription list or importing a template is different, because nothing about a second "Monthly newsletter" tells the API it is the same one. Send a source_ref and it does:

{ "system": "ms_customer_insights_journeys", "record_id": "topic-aaa" }
FieldMeaning
systemThe system the record comes from, from a fixed set: ms_customer_insights_journeys, clickdimensions, dynamics365, hubspot, mailchimp. Case and surrounding space do not matter. Pick one spelling and keep it: a value outside the set is a 422, deliberately, because two spellings of one system would be two namespaces and the same record would be imported twice.
record_idThat system's own id for the record. Up to 255 characters, and not blank.

What the create answers, in its outcome:

outcomeStatusWhat happened
created201It was made now, and the mapping was remembered.
matched_by_source_ref200This source_ref was already mapped. The existing row is returned and nothing was written to it — not its name, not its description, not a list's double opt-in setting, not a template's HTML.
overwritten200Only on a template, and only with "overwrite": true: the mapped template's HTML was replaced.
target_deleted200It was mapped to a row that has since been deleted, and nothing was created. The deletion was deliberate; a re-run does not undo it.

Three things follow, and they are the point:

  • A row you renamed stays renamed. The mapping is by source record, not by name, so tidying up an imported list or template — which is what importing is for — does not make the next run create a duplicate, and does not get undone.
  • An edited template is left alone. matched_by_source_ref writes nothing. Pass "overwrite": true to replace the HTML anyway; only the first replacement is recoverable, and an edit made since is lost. That is why it is off by default.
  • A deleted row is reported, not resurrected. You can tell target_deleted from created and decide.

A mapping lasts 180 days from the last time a run used it, and every one of the four outcomes counts as using it — so a migration that is still being re-run never expires, and one that was abandoned is cleaned up. A source_ref is optional: leave it out and the create behaves exactly as it always did.

GET /subscription-lists

Every list that has not been deleted, oldest first. Needs subscription-lists:read. Short and complete: this route is not paged.

{
  "lists": [
    {
      "id": "6f1c3f2a-12b4-4e0a-9f6c-6d7f9f0a1b2c",
      "name": "Monthly newsletter",
      "description": "Our monthly news",
      "is_default": false,
      "double_optin_required": false,
      "created_at": "2026-03-01T10:04:22.123456+00:00"
    }
  ]
}
FieldMeaning
idThe list's id. Opaque: compare it for equality.
nameWhat the list is called.
descriptionThe consent copy shown next to the subscribe checkbox.
is_defaultWhether this is the account's default unsubscribe list.
double_optin_requiredHow the list decides who may be mailed. true: only people with a confirmed opt-in on this list. false: anyone who has not opted out.
created_atISO 8601 in UTC.

These are the ids lists[].list_id takes on POST /contacts:bulk and PATCH /contacts/{contact_id}/subscriptions.

Errors: 403 insufficient_scope. See Conventions.

POST /subscription-lists

Create one list. Needs subscription-lists:write. Answers 201 when it was made and 200 when its source_ref was already mapped — see Re-running a migration.

{
  "name": "Monthly newsletter",
  "description": "Our monthly news",
  "source_ref": { "system": "ms_customer_insights_journeys", "record_id": "topic-aaa" }
}
FieldMeaning
nameWhat the list is called, up to 200 characters. Not blank, and not the name of a list that already exists.
descriptionThe consent copy shown next to the subscribe checkbox, up to 2,000 characters. Not blank: it is what makes consent informed, so write what subscribing means.
source_refOptional. Makes a re-run safe.
double_optin_requiredOptional, true or false (a JSON boolean: "true" is a 422). Default false. How the list decides who may be mailed: true, only people with a confirmed opt-in on this list; false, anyone who has not opted out. Honoured only when the list is created; a re-run never changes an existing list's setting, and the response reports the setting the list actually has — compare it with what you asked for. On a create it is honoured whether or not double opt-in is otherwise available for the account; while it is not, the customer sees the list's double opt-in on in Settings but cannot change it there.

The answer carries what became of the request and the list itself:

{
  "outcome": "created",
  "id": "6f1c3f2a-12b4-4e0a-9f6c-6d7f9f0a1b2c",
  "list": {
    "id": "6f1c3f2a-12b4-4e0a-9f6c-6d7f9f0a1b2c",
    "name": "Monthly newsletter",
    "description": "Our monthly news",
    "is_default": false,
    "double_optin_required": false,
    "created_at": "2026-03-01T10:04:22.123456+00:00"
  }
}

list is null when outcome is target_deleted, and id is then the deleted list's id. The list is created with double opt-in off unless you send "double_optin_required": true, and it is never made the account's default: which list an unsubscribe lands on is the customer's decision about their own account, so is_default is not a field this route accepts.

A list created with double opt-in starts with nobody on it who may be mailed. Write a confirmed preference for each person who opted in, with POST /contacts:bulk, and only they are. An existing list's setting is the customer's decision, and this route never changes it: on a matched_by_source_ref answer list.double_optin_required is the existing list's setting, which may not be the one you sent.

Errors: 409 subscription_list_name_in_use when a list that has not been deleted already has that name — send the source_ref instead of renaming, and a re-run is answered with the list the first run made; 422 for a blank or over-long value, a double_optin_required that is not a JSON boolean, an unknown source_ref.system, or a field the route does not accept; 403 insufficient_scope. See Conventions.

GET /email-templates

A page of templates, newest created first. Needs email-templates:read. Archived templates are not listed.

Query parameterMeaning
limitThe page size, 1 to 500. Default 100.
cursorThe next_cursor of the page before. Opaque: see Pagination.
{
  "items": [
    {
      "id": "tpl_9f0a1b2c3d4e",
      "name": "Spring promo",
      "state": "draft",
      "subject": "Spring is here",
      "preview_text": "Our best offers",
      "created_at": "2026-03-01T10:04:22.123456+00:00",
      "updated_at": "2026-10-16T09:30:00.123456+00:00"
    }
  ],
  "next_cursor": null,
  "count": 1
}
FieldMeaning
idThe template's id. Opaque: compare it for equality.
nameWhat the template is called.
statedraft, published or archived. An imported template starts as a draft. Values may be added.
subject, preview_textThe subject line and preview text, or null.
created_at, updated_atISO 8601 in UTC.

The HTML itself is not part of this API: a template is authored and previewed in the app.

Errors: 422 for a cursor this API did not send or a limit outside 1 to 500; and 403 insufficient_scope. See Conventions.

POST /email-templates/import

Import an HTML document as a draft template. Needs email-templates:write. Answers 201 when it was imported and 200 when its source_ref was already mapped — see Re-running a migration.

{
  "name": "Spring promo",
  "html": "<html><head><title>Spring is here</title></head><body><p>Hello</p></body></html>",
  "source_ref": { "system": "ms_customer_insights_journeys", "record_id": "tpl-1" }
}
FieldMeaning
nameWhat the template is called, up to 256 characters. Not blank.
htmlThe document, up to 2 MB.
subjectOptional. Taken from the document's <title> when omitted.
preview_textOptional. Taken from the document when omitted.
overwriteOptional, false by default. What to do when the source_ref is already mapped: see below.
source_refOptional. Makes a re-run safe.

The document is not stored as sent. It is sanitized, its merge fields are mapped to this app's own, and its unsubscribe link is rewritten, exactly as an upload through the app is; the result is a draft the customer can edit. A document that cannot be read as HTML, is empty, or is over the size limit is a 422 naming html, and so is one whose merge tags use JavaScript syntax (a {{ a ? b : c }} ternary): neither renderer can parse those, and accepting one would fail at send time for a real recipient.

{
  "outcome": "created",
  "id": "tpl_9f0a1b2c3d4e",
  "template": {
    "id": "tpl_9f0a1b2c3d4e",
    "name": "Spring promo",
    "state": "draft",
    "subject": "Spring is here",
    "preview_text": "Our best offers",
    "created_at": "2026-03-01T10:04:22.123456+00:00",
    "updated_at": "2026-03-01T10:04:22.123456+00:00"
  }
}

template is null when outcome is target_deleted, and id is then the deleted template's id. A template counts as deleted once it is archived, which is how the app retires one.

overwrite. Left at false, a second run with the same source_ref returns matched_by_source_ref and writes nothing: the customer has almost certainly edited the draft, and that edit is not a migration's to discard. With "overwrite": true the mapped template's HTML is replaced and the outcome is overwritten. Its name, subject and preview text are still not touched. Each replacement is recorded as a new version of the template, so whatever was there before (including edits made between overwrites) stays in the template's History in the app and can be restored from there. Use it when the source is authoritative and nobody has been editing.

Errors: 422 for a blank or over-long value, HTML that cannot be imported, or an unknown source_ref.system; 403 insufficient_scope. See Conventions.