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.
When a workflow requested a conversation rating and no teammate took part, conversation_rating.teammate now returns type: "workflow" with the workflow's id. Earlier versions return an admin or team reference for these ratings, and its id doesn't identify a teammate in your workspace.
This applies to the Retrieve, List and Search conversation endpoints, and to the conversation.rating.added webhook.
POST /contacts/search no longer interprets date filters in the workspace timezone:
- Standard timestamp attributes filter on the exact UTC second you pass, instead of being widened to the whole workspace-timezone day. A value the API returned now matches that contact exactly, and
>/<comparisons are second-accurate. Affected attributes:created_at,updated_at,signed_up_at,last_seen_at,last_contacted_at,last_replied_at,last_email_opened_at,last_email_clicked_at,ios_last_seen_at, andandroid_last_seen_at. - Customer-defined date attributes are anchored to UTC calendar days rather than workspace-timezone calendar days.
The standard timestamp attributes listed above are also reported as datetime instead of date by GET /data_attributes. Customer-defined date attributes are still reported as date.
Migrating from an earlier version:
- Standard timestamp attributes — a
=filter that previously matched anything on a given day now matches only that exact second. To keep matching a whole day, bracket it with>and<; both are exclusive, so pass the second before the day starts and the first second of the next day. - Customer-defined date attributes —
=still matches the whole day, so existing filters keep working. Only the day boundary moves, from your workspace timezone to UTC.
All stable API versions (2.16 and earlier) are unaffected and continue to bucket date filters by the whole workspace-timezone day.
A reply or note whose body contains <table> markup is stored as a real table, so a later GET returns table markup instead of a single paragraph of concatenated cell text. Previously the structure was discarded and only the cell text survived.
This is enabled per workspace and is still rolling out. Until your workspace has access, a <table> in a reply body continues to be flattened to plain text, as it is today.
The request contract is unchanged — body is still an HTML string and there is no new parameter. Conversation parts created before this change are unaffected, and a body with no <table> behaves exactly as it did.
A table-bearing body is checked before conversion: at most 200 rows and 50 columns per table, 20 tables and 10,000 cells per message, and no nested tables. Exceeding any limit returns 400 Bad Request naming the limit, rather than truncating. Note that when a body contains a table, the whole body takes a different conversion path, so other markup in it can come back differently — see Allowed HTML for conversation replies for the supported tags, the limits, and the known differences.
Affected endpoints:
POST /conversations/{id}/reply— bothmessage_type: commentandmessage_type: notePOST /tickets/{id}/reply— bothmessage_type: commentandmessage_type: note
On a ticket, message_type: comment is rejected for tracker tickets, and for back-office tickets that are not shared with the customer. Those accept message_type: note only. This is existing ticket-reply behaviour and is not specific to tables.
custom_attributes on conversation responses and webhook payloads no longer includes the attributes written by QA monitors and scorecards: the system-defined Review status, Reviewer, Has QA Notes and QA Notes Count, plus any conversation attribute a QA monitor or scorecard created.
On the conversation endpoints, read QA results as structured data with include_monitors=true and include_scorecards=true. Webhooks take no such parameter, so a conversation webhook payload no longer carries QA data at all — retrieve the conversation with those parameters when you need it.
This applies to GET /conversations, GET /conversations/{id}, POST /conversations/search, and conversation webhook payloads. Workspaces on API version 2.16 or earlier that were explicitly opted in to see these attributes continue to see them.
drop_reason can now report user_marked_as_spam and user_mailbox_full_suppressed, two new reasons a recipient's address was suppressed:
user_marked_as_spam— the address was suppressed after a spam complaint, from an email provider's feedback loop or an in-app report.user_mailbox_full_suppressed— the address was suppressed because its mailbox is full.
user_suppressed is still returned, but no longer implies either cause: it now covers only addresses suppressed manually (for example, by a support teammate) and drop reasons recorded before this change.
The article body now keeps dynamic content references when dynamic content is enabled for your workspace. Each reference is an element with the class intercom-dynamic-content and a data-intercom-dynamic-content-key attribute holding the dynamic content's key, wrapping the content shown for the article's locale: a <div> for block dynamic content and a <span> for inline dynamic content.
<div class="intercom-dynamic-content" data-intercom-dynamic-content-key="refund-policy"><p>You can request a refund within 30 days of purchase.</p></div>
<p>Call us on <span class="intercom-dynamic-content" data-intercom-dynamic-content-key="support-phone-number">+1-555-0100</span>.</p>To keep a reference when you create or update an article, send the element back in body. Its inner content is ignored, so edit the dynamic content itself to change what readers see. Removing the element removes the reference.
On earlier API versions, body shows inline dynamic content as the literal text #{{dynamic_content:KEY}}, which is kept if you send it back. Block dynamic content is left out, so updating an article's body from one of those versions removes its block references.
Affected endpoints:
You can now manage dynamic content: reusable content with a variation per locale, and optionally per brand, that you can insert into Help Center articles. Update a dynamic content once and every article that uses it shows the new value.
GET /dynamic_contentslists dynamic content, most recently updated first.POST /dynamic_contentscreates dynamic content. Setcontent_typetoinlinefor a short value such as a phone number, orblockfor one or more blocks of content sent as HTML inbody.GET /dynamic_contents/{id}retrieves a dynamic content.PUT /dynamic_contents/{id}updates its name, default locale or variations.variationsreplaces the full set.DELETE /dynamic_contents/{id}deletes a dynamic content that no article uses.
To make a locale or brand show nothing instead of falling back to the default content, send a variation with intentionally_empty: true. Any content sent with it is ignored, and responses report intentionally_empty on every variation.
Reading requires the read_dynamic_content scope, and creating, updating and deleting require the read_write_dynamic_content scope. The workspace must have dynamic content enabled; otherwise these endpoints respond 403.
The dynamic_content.created, dynamic_content.updated and dynamic_content.deleted webhook topics notify you of changes.
You can now read the version history of a data connector and annotate its versions. Versions are returned newest-first, and you can retrieve any version's definition, the connector's configuration at that version, without affecting the live connector.
Reading versions requires the read_data_connector_versions OAuth scope, and updating a note requires read_write_data_connector_versions.
GET /data_connectors/{id}/versions— List the versions of a data connector (newest-first, cursor-paginated withper_pageandstarting_after).GET /data_connectors/{id}/versions/{version_id}— Retrieve a single version.PATCH /data_connectors/{id}/versions/{version_id}— Update a version'snote. Send an empty string to clear it.
Use a version's entity_version_id as version_id.
A new top-level Folders API is now available at /folders for organizing content into hierarchical folder structures.
New endpoints:
GET /folders— List all folders (supports pagination andparent_folder_idfilter)POST /folders— Create a new folderGET /folders/{id}— Retrieve a folderPUT /folders/{id}— Update a folderDELETE /folders/{id}— Delete a folder (must be empty)
Article and Internal Article changes:
- A
folder_idfield has been added to article and internal article responses, as well as create and update request payloads, allowing you to organize articles into folders. - The
/internal_articles/searchendpoint'sfolder_idparameter now searches recursively through descendant folders.
Conversation parts and ticket parts now carry admin_assignee_id and team_assignee_id, the teammate and the team a part assigned the conversation or ticket to. They appear on conversations and tickets, and on the webhook payloads built from the same parts. They are named to match the admin_assignee_id and team_assignee_id already returned on conversations and tickets.
A part records a single assignee, so when a part assigns a team and a teammate at the same time, assigned_to reports the teammate only. This happens whenever a team's Round Robin distribution resolves the teammate inline, which meant the team a conversation was routed to was not reported at all. The new fields report each assignee separately, so team routing is visible regardless of the team's distribution method:
{
"type": "conversation_part",
"part_type": "assignment",
"assigned_to": { "type": "admin", "id": "991267715" },
"admin_assignee_id": 991267715,
"team_assignee_id": 5017691
}Both fields are populated for parts created before this change as well as new ones, so no backfill is needed to see historical team routing.
How each field behaves, using teammate 991267715, team 5017691 and bot 278:
| What the part did | assigned_to | admin_assignee_id | team_assignee_id |
|---|---|---|---|
| Assigned a team, resolving a teammate inline (Round Robin) | {"type": "admin", "id": "991267715"} | 991267715 | 5017691 |
| Assigned a teammate | {"type": "admin", "id": "991267715"} | 991267715 | null |
| Assigned a team | {"type": "team", "id": "5017691"} | null | 5017691 |
| Assigned a bot | {"type": "bot", "id": "278"} | 278 | null |
| Removed the team | {"type": "nobody_admin", "id": null} | null | 0 |
| Removed the teammate | {"type": "nobody_admin", "id": null} | 0 | null |
| Unassigned without naming which kind | {"type": "nobody_admin", "id": null} | null | null |
| Changed no assignment, for example a comment | null | null | null |
A bot appears in admin_assignee_id, because assignment treats anything that is not a team as a teammate. On endpoints that omit empty fields, both are absent rather than null — the same as the neighbouring assigned_to.
call_summary conversation parts now return event_details containing the id of the call the summary describes. A conversation can contain several calls, and therefore several call_summary parts side by side; previously nothing in the response indicated which call each summary belonged to.
New field:
event_details.call_id(string) — the id of the call the summary describes. It matches theidreturned by the Calls API and by thecall.started,call.ended,call.recording_availableandcall.transcription_availablewebhook topics, so it can be used as a direct join key.
Call summaries are written asynchronously after the call transcript has been processed, so the part's body can still be empty when you first read the conversation. event_details.call_id is present from the moment the part is created, regardless of whether the summary text has been generated yet.
Affected endpoints:
Conversation parts are only returned by endpoints that return a full conversation, so List all conversations and Search conversations are unaffected — their responses return conversation list items, which do not include conversation_parts.
You can now search for companies with the new Search companies endpoint (POST /companies/search), mirroring Search contacts. Filter by standard company fields, custom_attributes.{attribute_name}, and tag_id (which accepts the = and != operators). Results use cursor-based pagination via starting_after. Companies with no associated users are excluded from results.
You can now list contact activities with GET /contacts/activities. The endpoint returns a paginated feed of contact lifecycle events on a workspace level — such as creation, role update, merge, and deletion — ordered oldest first. Results use cursor-based pagination via starting_after.
You can now bulk create contacts with POST /contacts/bulk. This is a strict create: a contact matching an existing external_id or email is rejected rather than updated. It returns a 202 with a job you can poll via GET /contacts/bulk/{id}.
You can now bulk update contacts with PUT /contacts/bulk. Contacts are identified by id. It returns a 202 with a job you can poll via GET /contacts/bulk/{id}.
You can now bulk delete contacts with POST /contacts/bulk/delete. Contacts are identified by id and deleted. It returns a 202 with a job you can poll via GET /contacts/bulk/{id}.
You can now preview a contact merge with POST /contacts/merge/preview without applying the merge. It's a read-only dry run: send the same from (lead) and into (user) contact IDs you'd pass to POST /contacts/merge, and it reports whether the merge is allowed, without changing anything.
When allowed is true, the response includes a summary of every attribute change (with the resulting value on the surviving contact) and, for each object type, two counts: from — how many are on the lead and would move to the user — and into — how many the user already has. Counts are reported for conversations, tickets, phone calls, notes, and tags; conversations counts non-ticket conversations only, with tickets and phone calls (phone-lead merges only) reported separately. When allowed is false, it returns reasons, each with a stable code and a human-readable message explaining why.
You can now fetch a paginated list of the contacts in your workspace that are no longer active with GET /contacts/deleted. This helps you recover contacts whose id, email, or external_id you no longer have so you can permanently delete them.
Each entry includes a status explaining why the contact is inactive — one of archived, blocked, merged, pending_deletion, or permanently_deleted — along with its id, role, and an updated_at UNIX timestamp. merged_into_id is included only when status is merged. For permanently_deleted contacts, external_id and email are returned as null.
Results are paginated with page and per_page (default 10, maximum 150). A non-positive page or per_page returns HTTP 400 Bad Request.
The contact object response now includes email_verified field, so you can read back the value set when you create or update a contact. Setting email_verified field has been supported since API version 2.16.
email_verified is true only when the contact's current email address was marked verified through the Contacts API and has not changed since; it becomes false again if the contact's email changes, until the new address is verified. A lead with a verified email is reused when Intercom later matches an inbound email, or an outbound conversation or ticket, to that address, instead of creating a new lead.
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
When Fin is unsure of an answer it can consult a teammate before replying, proposing a draft that the teammate approves, steers with feedback, or takes over. GET /conversations/{id}/hitl_events returns those consultations as a list of events, oldest first, across every consultation on the conversation.
{
"type": "list",
"data": [
{
"hitl_cycle_id": "3f2a91c4-8e77-4d61-9a2b-0c5d7e1f4a80",
"event_type": "fin_teammate_input_received",
"conversation_part_id": null,
"rule_id": "88",
"rule_name": "Refunds over $100",
"created_at": 1767225701,
"data": {
"round_index": 1,
"teammate_input": "Tell them 5 business days.",
"reviewer_admin_id": "4001",
"regeneration_outcome": "answer"
}
}
]
}Which fields appear in data depends on event_type:
event_type | data fields |
|---|---|
fin_consultation_started | channel, rule_threshold_percent, confidence_percent, sharp_ask, ask_rationale, issue_summary, customer_question |
fin_draft_proposed | draft |
fin_teammate_input_received | round_index, teammate_input, reviewer_admin_id, regeneration_outcome |
fin_draft_revised | round_index, revised_draft, regeneration_outcome |
fin_consultation_finished | outcome, approved_draft_type, final_draft, sent_text, reviewer_admin_id, duration_seconds, rounds_count, payload_truncated |
Every consultation produces a fin_consultation_started and a fin_consultation_finished event. fin_draft_proposed appears when Fin proposed a draft, fin_teammate_input_received once per round of teammate feedback, and fin_draft_revised once per round where Fin produced a revised draft in response. A consultation ends with an outcome of approved, taken_over, timed_out_sent, timed_out_escalated or superseded.
hitl_cycle_id identifies the consultation, so it is the same on every event of one consultation rather than unique per event. Group events by it to reconstruct a single consultation. A conversation can hold several — one that replaces an outstanding consultation finishes as superseded.
A few things to know when reading the response:
- Events appear only once a consultation has ended, so one still awaiting a teammate is absent.
conversation_part_idisnullwhere an event has no corresponding conversation part. Teammate feedback given through the inbox creates no part, a revised draft has no part of its own, and a consultation that escalated or was superseded sent no reply.rule_id,rule_nameandrule_threshold_percentare recorded when the consultation begins and not updated afterwards, so a rule renamed or retuned since will not match its current settings. They arenullfor consultations no rule triggered.reviewer_admin_idis the teammate involved in that event: onfin_teammate_input_receivedwhoever gave the feedback, onfin_consultation_finishedwhoever approved the draft or took over. On a consultation with several rounds these can be different teammates.rounds_countcounts every round of feedback the consultation had, while only the ten most recent are returned as events. A heavily steered consultation therefore returns fewerfin_teammate_input_receivedevents thanrounds_countreports.- Redacting a conversation removes this data. Depending on how it was redacted, the conversation returns no events, or events whose text fields are all
null.
Requires the read_conversations scope, and the conversation must be one the caller can access.
The ai_agent.content_sources list on a conversation now includes the internal articles Fin used, reported with content_type of internal_article alongside the article's title, locale, and a link your teammates can open.
Earlier versions leave internal articles out of the list, so a conversation answered from one returns fewer sources than Fin actually used.
You can now delete a single contact or company note with DELETE /notes/{id}. Notes of other kinds cannot be deleted through the API. Requires the write_users_companies OAuth scope.
Each entry in the scorecards array returned with include_scorecards=true now tells you who the review is assigned to and where that review stands, alongside the existing reviewed_teammate (the teammate or AI agent whose handling was scored):
reviewer— who the review is assigned to, as{ "type": "ai" }when it is assigned to Fin or{ "type": "admin", "admin_id": "..." }when it is assigned to a teammate. Null when the scorecard has no review yet or the review is unassigned.review_status— the status shown in the QA reviews list:unreviewed,submitted,passed_review,needs_fix,wont_fixorfix_complete. Null when the scorecard has no review.
{
"scorecard_id": "67890",
"name": "Standard QA Review",
"passed": true,
"score": 0.85,
"reviewed_teammate": { "type": "admin", "admin_id": "991267715" },
"reviewer": { "type": "admin", "admin_id": "884120017" },
"review_status": "submitted"
}The fields are returned by GET /conversations, GET /conversations/{id} and POST /conversations/search.
Each entry in the scorecards array returned with include_scorecards=true now tells you who acted on the review, alongside reviewer (who the review is assigned to):
submitted_by— who last submitted the review, as{ "type": "ai" }when Fin submitted it or{ "type": "admin", "admin_id": "..." }when a teammate did. This can be a different teammate from thereviewer. Null while the review is unreviewed or when the scorecard has no review.revised_by— who last changed the review after it was submitted, in the same shape. Null while the review is unreviewed, when it has not been changed since it was submitted, or when the scorecard has no review.
{
"scorecard_id": "67890",
"name": "Standard QA Review",
"reviewer": { "type": "admin", "admin_id": "884120017" },
"review_status": "submitted",
"submitted_by": { "type": "admin", "admin_id": "884120017" },
"revised_by": null
}The fields are returned by GET /conversations, GET /conversations/{id} and POST /conversations/search.
Each entry in the scorecards array returned with include_scorecards=true now carries the notes teammates left on that scorecard's QA review, so you can read the reviewer's commentary next to the score it explains:
notes— an array holding each note on the linked review, oldest first. Empty when the scorecard has no linked review.- Each note has an
id,created_atandupdated_attimestamps, anauthorand abody.authoris{ "type": "admin", "admin_id": "..." }when a teammate wrote the note,{ "type": "ai" }when Fin did, and null when the teammate who wrote it has since been deleted. The body is the plain text the teammate typed; it is not rendered as HTML.
{
"scorecard_id": "67890",
"name": "Standard QA Review",
"reviewer": { "type": "admin", "admin_id": "884120017" },
"review_status": "submitted",
"notes": [
{
"id": "9001",
"created_at": 1734537900,
"updated_at": 1734537900,
"author": { "type": "admin", "admin_id": "884120017" },
"body": "Great empathy in the second reply."
}
]
}The field is returned by GET /conversations, GET /conversations/{id} and POST /conversations/search.
Each evaluator in a scorecard entry's evaluators array previously exposed only ids. It now also carries the human-readable name and result labels, so you can display a scorecard without looking up every id yourself:
name— the evaluator's (criterion's) name. Null if it could not be resolved.result.value_label— the label of the rating option matchingresult.value(for exampleNeeds improvement). Omitted when the label could not be resolved.result.reasons— the structured reasons chosen for the result, each with aname. Omitted when none were chosen.
{
"evaluator_id": "54321",
"name": "Tone of voice",
"result": {
"value": "pass",
"value_label": "Pass",
"source": "ai",
"reasoning": "The agent acknowledged the issue and resolved it within the same response.",
"reasons": [
{ "name": "Clear and concise" },
{ "name": "Empathetic tone" }
]
}
}The fields are returned by GET /conversations, GET /conversations/{id} and POST /conversations/search.
An ai_agent_event conversation part with the event type otp_email_verification_failed now carries a reason naming why the one-time passcode email could not verify the contact:
{
"part_type": "ai_agent_event",
"event_details": {
"event": {
"type": "otp_email_verification_failed",
"value": "OTP email verification failed",
"data": {
"otp_email_verification_failed": {
"reason": "no_email"
}
}
}
}
}The reason you can receive today is no_email: the workspace requires email verification before Fin runs an authenticated action, and the contact has no email address on record.
Treat reason as a free-form string. More reasons may follow, so fall back to the event's value for a reason you do not recognise. A failure recorded without a reason returns an empty data object.
This applies to the conversation and conversation part endpoints and to conversation webhook topics. Earlier versions continue to return an empty data object for this event.
Assigning a closed conversation reopens it. You can now keep it closed by passing skip_reopen on an assignment to POST /conversations/{id}/parts:
{
"message_type": "assignment",
"type": "admin",
"admin_id": "991267708",
"assignee_id": "4324241",
"skip_reopen": true
}The conversation keeps its original close timestamp, no reopen event is recorded, and the assignment does not count as a new closure in reporting. This lets you reassign a batch of closed conversations — when a teammate leaves, or an inbox is retired — without changing your closure metrics.
skip_reopen is optional and defaults to false, so assignment behaves as before unless you set it. Open and snoozed conversations are unaffected.