For changes that have been updated across all versions, see the Unversioned changes page.
There are breaking changes in this version, which are detailed below.
A conversation part whose type this API version does not recognise now returns part_type: "unknown". Earlier versions return comment for those parts.
This affects a small number of part types that were retired from the product. If you branch on part_type, treat unknown as a part you should skip rather than as a message — previously such a part was indistinguishable from a real comment.
unknown is a value you may receive but cannot filter on: it is rejected by include_part_types and exclude_part_types with a HTTP 422 Unprocessable Entity, because the set of types behind it is open-ended. To leave those parts out, name the types you do want with include_part_types.
Ticket parts are unaffected and continue to report comment.
Conversation responses with include_scorecards=true no longer include these fields:
scorecard_version_idon each entry inscorecardsreason_idson eachresultin an entry'sevaluators
scorecard_id still identifies the scorecard. result.reasons gives the reason names. evaluator_id and result.value are unchanged.
This applies to GET /conversations, GET /conversations/{id} and POST /conversations/search. API version 2.17 and earlier still return both fields.
When a conversation body or conversation part body is returned as plain text, a line break inside a paragraph now renders as a newline (\n) instead of a space. Multi-line notes, such as an AI-generated conversation summary, keep the line-by-line layout shown in the Inbox. Earlier API versions are unchanged.
Affected endpoints:
GET /conversations/{id}(withdisplay_as=plaintext)GET /conversations(withdisplay_as=plaintext)- Conversation webhook topics, for example
conversation.admin.notedandconversation.admin.replied
The GET /export/reporting_data/get_datasets endpoint now returns a ticket field's qualified_id with its ticket type. For example, a "Priority" field on the "Bug" ticket type is now ticket.Bug.Priority instead of ticket.Priority.
The POST /export/reporting_data/enqueue endpoint accepts only the new format for ticket fields. A ticket field id without its ticket type returns a 400 error. CSV column headers do not change.
The code field on an error object is now always a string. Previously, a few request validation errors (for example a malformed company data structure) returned an HTTP status code as an integer here instead, such as 422. All error codes now use the same string format.
Audience and dynamic content names are HTML-unescaped when written, as company names already are, so reading a name and sending it back no longer double-escapes it.
The PUT /contacts/bulk endpoint now accepts these fields on each contact, with the same rules as PUT /contacts/{id}:
rolesigned_up_atlast_seen_atsms_consentunsubscribed_from_sms
Relationship attributes in custom_attributes now also link the contact to the custom object records you send.
A contact with an invalid value fails on its own. The rest of the job is still applied, and its state ends as completed_with_errors.
A redirect can now send visitors to a help center's home page, not just to an article or collection. Set target_type to help_center when you create a redirect. The locale picks which language of the home page visitors land on.
target_id is optional for these redirects and defaults to the help center in the request path. If you send it, it must match that help center, or the request responds 422. The list and retrieve endpoints return home page redirects with target_type set to help_center and target_id set to the help center's id.
When you create or update an article, empty <p> tags in body are now kept as blank paragraphs instead of being dropped.
POST /contacts/merge now accepts a source contact (from) with a role of user as well as lead. The destination (into) must still be a user.
When you merge a user into another user, the source user's attribute values (standard and custom) are discarded and are not copied onto the target. The target user keeps its own attribute values. Records associated with the source, such as conversations, notes, and tags, are still reassigned to the target.
Retrieving a conversation accepts two new optional parameters. include_part_types returns only the part types you name; exclude_part_types returns everything except them. Both take a comma-separated list of up to 50 part types, and a request can use one or the other, not both.
The filter is applied before the limit on how many parts are returned, so you get the newest parts that match rather than whichever parts fit inside the limit. A conversation with thousands of assignment and workflow parts will still return your most recent comments.
GET /conversations/123?include_part_types=comment,noteThe values you pass are the same names this version returns in each part's part_type field, so you can read a conversation once and filter by what you see. An unrecognised name returns a 422 rather than being ignored — a typo fails loudly instead of quietly returning an unfiltered list.
Two names are worth knowing about before you filter:
unknowncannot be filtered on. A part type this version does not recognise is returned asunknown, and passing that name returns a422— the set of types behind it is open-ended. Useinclude_part_typesto name the types you do want instead.- The part type that versions below 2.6 return as
note_and_reopenis namednote_and_unsnoozehere.
The GET /conversations/{id} endpoint now accepts two query parameters:
page(integer): When sent, the conversation's parts are returned a page at a time, starting from the oldest part. Page 1 holds the oldest parts, and each page lists its parts oldest first.per_page(integer): The number of parts per page, from 1 to 500. Defaults to 20. It is ignored unlesspageis sent.
When page is sent, conversation_parts includes a pages object with type, page, per_page, total_pages and next. next is the URL of the next page, and is null on the last page. total_count is the number of parts on the current page.
Part type and created-at filters are applied before paging. An invalid page or per_page returns a 400. Requests without page are unchanged and still return the 500 most recent parts.
The teammates endpoint returns the role a teammate holds, but only the roles currently assigned, and only their id and name. You can now list every role in your workspace and look one up by id, each with its description.
GET /roles— List every role in the workspace.GET /roles/{id}— Retrieve a single role. Responds404if no role with that id exists in your workspace.
An access token needs the read_admins scope. Each role returns its id, name and description; description is null when the role does not have one. Roles are read-only over the API — they are created and edited in your workspace settings.
Contact references, such as the contacts list on conversations and tickets, now include the contact's email alongside type, id and external_id. You no longer need a follow-up GET /contacts/{id} call to find out which email address a conversation is with. email is null for a contact with no email on record.
A standalone_platform_handoff conversation part now returns event_details.handoff, describing the handoff Fin attempted to an external platform, so you can tell what kind of handoff happened and whether it worked without reading the part's body.
New field:
event_details.handoff.type(string) — the handoff type:zendesk_ticket,zendesk_agent,salesforce_case,salesforce_in_app_messaging,salesforce_team,external_agentorexternal_ticket. Older parts can also returnsalesforce_chat.event_details.handoff.success(boolean ornull) —truewhen the handoff completed andfalsewhen it failed.nullmeans no outcome was recorded on this part, for example a Salesforce in-app messaging handoff that was only initiated.event_details.handoff.data(object) — always present. For a successfulsalesforce_casehandoff it holds the created Case'scase_idandcase_number; otherwise it is an empty object.
"event_details": {
"handoff": {
"type": "salesforce_case",
"success": true,
"data": { "case_id": "500XX0000001", "case_number": "00001234" }
}
}The POST /ecommerce/connectors/{id}/catalog endpoint is now available. Use it to sync your product catalog to an ecommerce connector.
Single-file upload — POST a catalog JSON file in one request:
curl -X POST "https://api.intercom.io/ecommerce/connectors/{id}/catalog" \
-H "Authorization: Bearer {access_token}" \
-H "Intercom-Version: 2.18" \
-F "catalog=@products.json;type=application/json"Multipart upload — split large catalogs across multiple requests:
- POST the first part with
finalize=false. The response includes animport_job_idandnext_part_number. - POST each subsequent part with
finalize=false, yourimport_job_id, and an incrementingpart_number. - POST a final request with
finalize=trueand yourimport_job_id(no file needed) to trigger the sync.
Each catalog file must be a valid JSON array of objects, up to 99 MB per request. Part uploads are idempotent — retrying the same part_number is safe.
The POST /ecommerce/connectors/{id}/events endpoint is now available. Use it to send one product change at a time, so Fin's product knowledge stays current between full catalog uploads. Upload a full catalog first — until you do, this endpoint returns 409 with type catalog_not_uploaded.
Every event needs a type and an occurred_at timestamp (ISO 8601). There are three event types:
product.createdandproduct.updated— send the full product object indata.product, with the same fields as a catalog product.id,title,urlandupdated_atare required.product.deleted— send onlydata.product_id.
The endpoint returns 202 with an event_id once the event is queued. Processing is asynchronous.
Conversation parts and the conversation source now include user_ip, the IP address of the end user who sent the part or opened the conversation. It is null when the IP address isn't available.
Conversation parts can now include receipts, describing what happened to the part for each recipient: each recipient's own delivery state, the reason when Intercom could not send to them, and an overall_state for the part. A part reports partially_sent when some of its recipients have it and others do not.
You can now manage which companies a contact has been granted access to. A granted company lets the contact read that company's tickets in the customer portal without being a member of it — useful when a consultant or agency contact needs visibility of the companies they act for.
Grants are separate from company membership: granting a company does not attach the contact to it, and GET /contacts/{id}/companies does not include grants.
GET /contacts/{contact_id}/granted_companies— List a contact's granted companies. Paginated withpageandper_page(default 50, maximum 150), sorted by company id, andorderacceptsascordesc.PUT /contacts/{contact_id}/granted_companies— Replace the contact's full granted set.DELETE /contacts/{contact_id}/granted_companies/{company_id}— Remove one company from the granted set, returning204with no body.
Companies are identified by the company_id you set on them, so what GET returns is what you send back to PUT.
PUT is a replacement, not an append. The company_ids you send become the contact's entire granted set and any id you leave out is dropped, so to add a company you send the existing ids plus the new one, and to drop every grant you send company_ids: []. Omitting company_ids altogether is rejected with 400 parameter_not_found rather than treated as an empty list. A contact can be granted at most 100 companies.
DELETE is not idempotent. Removing a company the contact is not currently granted returns 404 company_not_found, so repeating a successful call returns 404 rather than 204.
Listing works with the read_users_companies OAuth scope; replacing and removing require the write_users_companies OAuth scope.
PUT and DELETE return 404 with the code granted_companies_not_enabled unless granted companies are enabled for the workspace, and GET returns an empty list. Contact Intercom Support to enable it.
You can now read the third-party credentials stored in your workspace. A data connector authenticates with a stored credential, and these endpoints let you find the id of the one you want it to use.
GET /auth_tokens— List the workspace's stored credentials. Paginated withper_page(default 20, maximum 50) andstarting_after.GET /auth_tokens/{id}— Retrieve one credential by id.
Both endpoints need an access token with the read_auth_tokens scope, and both are read-only. They return metadata only — the credential value, and its refresh and OAuth configuration, are never included. The list is not exhaustive: credentials a teammate created are listed, as are those created by some Intercom integrations, but credentials installed by an app package, those created by most other integrations, and those minted for an individual teammate's MCP access are not. A credential missing from the list also returns 404 from the retrieve endpoint.
You can also store a third-party credential in your workspace over the API, and update one you already have. Together with the read endpoints, you can set up a data connector and the credential it authenticates with without leaving your integration.
POST /auth_tokens— Store a new credential.name,token_valueandrequest_header_keyare required;token_prefixanddescriptionare optional.PATCH /auth_tokens/{id}— Update a credential'sname,request_header_key,token_prefixordescription. A field you omit keeps its current value.
Both endpoints need an access token with the write_auth_tokens scope, and that token must be associated with a teammate — an app-scoped token gets a 403. Only credentials with a token_type of text can be updated; any other type returns 400 from the update endpoint.
The credential value you send is stored encrypted and is never returned by any endpoint, including the one that created it. It also cannot be changed through the update endpoint. A credential the list endpoint does not return cannot be updated either, and responds with 404.
POST /auth_tokens and PATCH /auth_tokens/{id} ignore domain if it is sent. Set a credential's domain in the Intercom settings instead. domain is still returned on every auth token response, and an update leaves it unchanged. request_header_key can't be changed on a credential that has a domain.
POST /auth_tokens also accepts token_type: oauth. Give it an oauth_configuration with token_url, client_id and client_secret (scope, audience and request_format are optional), and Intercom exchanges the client credentials for an access token using the OAuth client-credentials grant, then keeps it refreshed automatically. Requests sent with the credential use the Authorization header with a Bearer prefix.
token_value, request_header_key and token_prefix must be omitted for an oauth credential — they only apply to text credentials, which remain the default token_type. The client secret you send is never returned by any endpoint. If Intercom can't obtain an access token from token_url with the given client credentials, creation fails with 400.
Each turn in a call transcript now carries an optional speaker_name, the display name of the teammate or contact who was speaking. Previously a turn only had the generic diarization label — Teammate 1, User — so you had to work out who said what.
speaker_name is omitted when the speaker cannot be resolved or has no name, for example a contact whose record was scrambled by a deletion request. A contact who has been merged into another resolves to the name on the surviving record, and a turn spoken by a teammate who has since been deleted reports speaker_id -1 with the name Deleted teammate. Fall back to speaker_label when speaker_name is absent.
Affected endpoints:
The contact object now returns whatsapp_number, the WhatsApp number stored for the contact (for example, +34666666666), or null when the contact has none. It is read-only: sending it when creating or updating a contact has no effect.
Affected endpoints:
GET /contacts/{id}GET /contacts/find_by_external_id/{external_id}GET /contactsPOST /contacts/searchPOST /contactsPUT /contacts/{id}POST /contacts/mergeGET /companies/{id}/contactsPOST /visitors/convert- Webhook topics whose payload includes a contact object
You can now see which team a conversation's active handling time belongs to. GET /conversations/{id}/team_handling_segments returns the periods teammates actively handled the conversation, each tagged with the team assigned to it at the time.
- Each period covers a single team assignment and never continues across a team change. Moving a conversation to another team usually unassigns the teammate as well, so their period ends with an
end_reasonofassignment, and the next period starts, with astart_reasonofnull, when a teammate picks the conversation up again. You'll only seeteam_changewhen the team changes and the same teammate keeps working without a break. end_reasonis one ofswitch,assignment,reassign,snooze,close,away,auto_away,session_droporteam_change.teamisnullfor a period when no team was assigned.ended_atandend_reasonarenullwhile a period is still open.- The response doesn't say which teammate did the work.
Requires the read_conversations scope. Responds 404 if the conversation does not exist, and 403 if the teammate cannot access it.
You can now receive real-time notifications when Intercom detects a spike in conversation volume, using the new incident.created and incident.updated webhook topics. The payload carries the incident's generated title and description, the conversation topics it covers, and excess_topic_conversations — the number of conversations above the expected volume for those topics.
incident.updated fires when a new spike of conversations is grouped into an existing incident, which can also regenerate its title and description — treat the payload as a full replacement rather than diffing it. There is no incident.closed topic: incidents close when detection stops seeing activity, and no event is sent when they do.
Both topics require the Export reporting data permission. See the webhook topics reference for payload details, and Set up Webhooks to configure a subscription.