Route reference
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 path | Needs | What it is for |
|---|---|---|
GET /.well-known/oauth-protected-resource/api/public/v1 | Nothing | Where to sign in, and what to ask for. |
GET /meta | An application the organization has let in; no particular scope | Which tenant and which credential the API believes you are. |
GET /integrations | integrations:read | The connected integrations, and how far inbound sync reaches. |
GET /contacts | contacts:read | A page of contacts, oldest change first; find one by address or external id. |
GET /contacts/{contact_id} | contacts:read | One contact. |
POST /contacts | contacts:write | Create one contact. |
PATCH /contacts/{contact_id} | contacts:write | Change a contact's fields. |
POST /contacts/{contact_id}/emails | contacts:write | Add an address to a contact. |
GET /contacts/{contact_id}/subscriptions | consent:read | Which lists a contact is on, and where that came from. |
PATCH /contacts/{contact_id}/subscriptions | consent:write | Record a contact's opt-ins and opt-outs, leaving every list not named alone. |
POST /contacts:bulk | contacts:write, consent:write, or both | Write up to 1,000 contacts' fields, addresses and consent in one request. |
GET /subscription-lists | subscription-lists:read | Every list that has not been deleted. |
POST /subscription-lists | subscription-lists:write | Create one list, re-run-safely. |
GET /email-templates | email-templates:read | A page of templates, newest first. |
POST /email-templates/import | email-templates:write | Import 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"]
}
| Field | Meaning |
|---|---|
resource | The API's canonical address. Send it as resource when you request a token, exactly as written; it becomes the token's audience. |
authorization_servers | The issuers that issue tokens for this API. Fetch an issuer's own metadata to find its endpoints. |
scopes_supported | The 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_supported | header: 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"
}
| Field | Meaning |
|---|---|
api_version | v1. The same value as the X-Tstr-Api-Version header. |
tenant_id | Identifies the tenant. See below. |
scopes | The scopes this credential may use right now, sorted. See below. |
client.id | The OAuth client the token was issued to: for a client identified by a metadata document, that document's URL. |
client.name | A label for the application. Today it is the same string as client.id; do not build on the two differing. |
server_time | The 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
}
]
}
| Field | Meaning |
|---|---|
category | The kind of integration. crm for a CRM. |
provider | The provider: dynamics365, hubspot or pipedrive for a CRM. |
status | connecting, connected, error, disconnected or mapping_pending. More may be added. |
scope_mechanism | How 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_ids | With 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_flag | With field_flag, whether sync is limited to flagged records. false otherwise. |
scope_stale | true 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
connectedones. Checkstatusbefore 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 parameter | Meaning |
|---|---|
email | Only the contact that holds this address, whichever of its addresses it is. Case does not matter. |
provider and external_id | Only 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_since | Only contacts that changed at or after this instant, ISO 8601. A time with no offset is UTC. |
limit | The page size, 1 to 500. Default 100. |
cursor | The 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.
| Field | Meaning |
|---|---|
id | The contact's id. Opaque: compare it for equality. |
status | active or archived. Archiving retires a contact and deletes nothing. |
emails | Every address the contact holds, the primary first. id is the address's own id. label is null when there is none. |
primary_email | The primary address, or null for a contact that has none (one the CRM knows only by phone). |
tags | The contact's tags. |
custom_fields | The account's own contact fields, by key. A field that was never set is absent from the object. |
do_not_contact | Never contact on any channel. |
do_not_contact_email | The 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_sms | Never contact by SMS. |
salutation, lifecycle_stage, lead_source, preferred_channel | The key of an option of that field, as the account defines its options. |
birthday | ISO 8601 date. |
country | A two-letter ISO country code. |
timezone | An IANA time zone name. |
created_at, updated_at | ISO 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" }
}
| Field | Meaning |
|---|---|
emails | Required, 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_channel | Optional 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_fields | Optional. 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_sms | Optional 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 whatcontacts:writemeans. 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 is409 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_fieldsis merged into the contact's own, key by key: a value is set, anullor blank removes that key, and every other key stays.statusisactiveorarchived, andnullleaves it as it is.do_not_contact,do_not_contact_emailanddo_not_contact_smstaketrue, andnullor leaving the field out leaves the flag as it is.falseis refused with422 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'sfalseis never applied either.- Anything the body does not list is refused whole with
422, as forPOST /contacts. Addresses are changed throughPOST /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".
| Field | Meaning |
|---|---|
status | What 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_detail | How 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_optin | Whether 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_at | When the person confirmed or originally agreed. For migration consent it is the original time, not the time it was imported. |
opted_out_at | When the withdrawal was recorded here. |
effective | What 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_subscribed | Whether 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_suppressed | The 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"
}
| Field | Meaning |
|---|---|
provenance | Required. 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. |
lists | Required, 1 to 50. See the entry below. |
address | Optional. 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:
| Field | Meaning |
|---|---|
list_id | The subscription list. One that does not exist or has been deleted is reported as list_not_found. |
status | confirmed (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_at | Required 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_at | Accepted and not stored. A withdrawal is recorded at the moment it is received. |
consent_text_snapshot | The wording the person agreed to, kept with a confirmed entry. |
method_detail, source_record_id | Kept 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_preservedorskipped_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: falseand the originalconfirmed_at. Every change that happened appends one row to the contact's consent history; a repeat that changes nothing appends none, and saysalready_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.
| Field | Meaning |
|---|---|
default_provenance | Required. 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_missing | false 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. |
items | Required, 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 key | Needs | What it does |
|---|---|---|
match | Who 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_missing | contacts:write | contact 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. |
global | consent: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. |
lists | consent:write | Per-list preferences, as lists above. |
method_detail, source_record_id | Kept 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.
| Limit | Value | When it is exceeded |
|---|---|---|
| Items in a request | 1,000 | 400 too_many_items, nothing done |
lists entries in an item | 50 | 422, nothing done |
emails entries in an item | 10 | 422, 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 withmethod: migrationand yoursource_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 theIdempotency-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 asource_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.
outcome | Meaning |
|---|---|
already_in_state | Nothing to write: the state is what you asked for. A re-run is this throughout. |
address_added | An address entry: it was absent and has been added. For the item's own outcome it counts as applied. |
applied | State written: a grant recorded, a withdrawal recorded, a flag set, a contact made or changed. |
skipped_pending_preserved | A grant that was not applied: a request awaiting confirmation (or one that expired unanswered) is on file. |
skipped_opt_out_preserved | A grant that was not applied: a withdrawal is on file. |
list_not_found | The list is not there. Create it, then run again. |
contact_not_found | Nobody 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_found | The 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_contact | An address entry: another contact holds it, and it was left there. |
ambiguous_match | An external id matched several contacts, and the write was a grant or a creation. |
invalid | Something in the item is wrong; reason is a stable code and detail a sentence. |
forbidden_section | The 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" }
| Field | Meaning |
|---|---|
system | The 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_id | That system's own id for the record. Up to 255 characters, and not blank. |
What the create answers, in its outcome:
outcome | Status | What happened |
|---|---|---|
created | 201 | It was made now, and the mapping was remembered. |
matched_by_source_ref | 200 | This 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. |
overwritten | 200 | Only on a template, and only with "overwrite": true: the mapped template's HTML was replaced. |
target_deleted | 200 | It 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_refwrites nothing. Pass"overwrite": trueto 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_deletedfromcreatedand 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"
}
]
}
| Field | Meaning |
|---|---|
id | The list's id. Opaque: compare it for equality. |
name | What the list is called. |
description | The consent copy shown next to the subscribe checkbox. |
is_default | Whether this is the account's default unsubscribe list. |
double_optin_required | 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. |
created_at | ISO 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" }
}
| Field | Meaning |
|---|---|
name | What the list is called, up to 200 characters. Not blank, and not the name of a list that already exists. |
description | The 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_ref | Optional. Makes a re-run safe. |
double_optin_required | Optional, 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 parameter | Meaning |
|---|---|
limit | The page size, 1 to 500. Default 100. |
cursor | The 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
}
| Field | Meaning |
|---|---|
id | The template's id. Opaque: compare it for equality. |
name | What the template is called. |
state | draft, published or archived. An imported template starts as a draft. Values may be added. |
subject, preview_text | The subject line and preview text, or null. |
created_at, updated_at | ISO 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" }
}
| Field | Meaning |
|---|---|
name | What the template is called, up to 256 characters. Not blank. |
html | The document, up to 2 MB. |
subject | Optional. Taken from the document's <title> when omitted. |
preview_text | Optional. Taken from the document when omitted. |
overwrite | Optional, false by default. What to do when the source_ref is already mapped: see below. |
source_ref | Optional. 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.