Skip to content

Changelog (2.17)

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.

Breaking Changes

Breaking change: conversation rating teammate returns workflow for workflow ratings

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.

Breaking change: Contact Search date filtering is anchored to UTC

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, and android_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.

Conversations: HTML tables in replies and notes are preserved

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:

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.

Changes

QA review attributes removed from conversation custom_attributes

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.

Conversations: new drop_reason values for spam complaints and full mailboxes

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.

Articles: dynamic content in article bodies

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:

New Features

Dynamic content

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.

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.

Data connectors: version history

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.

Use a version's entity_version_id as version_id.

Folder Management API

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 and parent_folder_id filter)
  • POST /folders — Create a new folder
  • GET /folders/{id} — Retrieve a folder
  • PUT /folders/{id} — Update a folder
  • DELETE /folders/{id} — Delete a folder (must be empty)

Article and Internal Article changes:

  • A folder_id field 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/search endpoint's folder_id parameter now searches recursively through descendant folders.

Conversation and ticket parts report the teammate and team they assigned

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 didassigned_toadmin_assignee_idteam_assignee_id
Assigned a team, resolving a teammate inline (Round Robin){"type": "admin", "id": "991267715"}9912677155017691
Assigned a teammate{"type": "admin", "id": "991267715"}991267715null
Assigned a team{"type": "team", "id": "5017691"}null5017691
Assigned a bot{"type": "bot", "id": "278"}278null
Removed the team{"type": "nobody_admin", "id": null}null0
Removed the teammate{"type": "nobody_admin", "id": null}0null
Unassigned without naming which kind{"type": "nobody_admin", "id": null}nullnull
Changed no assignment, for example a commentnullnullnull

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.

Conversations: call_summary parts now identify the call they describe

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 the id returned by the Calls API and by the call.started, call.ended, call.recording_available and call.transcription_available webhook 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.

Search companies

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.

List contact activities

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.

New contacts bulk endpoints

Bulk create contacts

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}.

Bulk update contacts

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}.

Bulk delete contacts

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}.

Preview a contact merge

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.

List a workspace's deleted contacts

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.

Contacts: read email_verified back on the contact object

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:

Retrieve the human-in-the-loop events for a conversation

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_typedata fields
fin_consultation_startedchannel, rule_threshold_percent, confidence_percent, sharp_ask, ask_rationale, issue_summary, customer_question
fin_draft_proposeddraft
fin_teammate_input_receivedround_index, teammate_input, reviewer_admin_id, regeneration_outcome
fin_draft_revisedround_index, revised_draft, regeneration_outcome
fin_consultation_finishedoutcome, 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_id is null where 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_name and rule_threshold_percent are recorded when the consultation begins and not updated afterwards, so a rule renamed or retuned since will not match its current settings. They are null for consultations no rule triggered.
  • reviewer_admin_id is the teammate involved in that event: on fin_teammate_input_received whoever gave the feedback, on fin_consultation_finished whoever approved the draft or took over. On a consultation with several rounds these can be different teammates.
  • rounds_count counts every round of feedback the consultation had, while only the ten most recent are returned as events. A heavily steered consultation therefore returns fewer fin_teammate_input_received events than rounds_count reports.
  • 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.

Internal articles in AI Agent content sources

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.

Delete a note

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.

Scorecard entries now include the reviewer and review status

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_fix or fix_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.

Scorecard entries now include who submitted and who last revised the review

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 the reviewer. 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.

Scorecard entries now include review notes

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_at and updated_at timestamps, an author and a body. author is { "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.

Scorecard evaluators now include readable names, labels and reasons

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 matching result.value (for example Needs improvement). Omitted when the label could not be resolved.
  • result.reasons — the structured reasons chosen for the result, each with a name. 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.

Failed OTP email verifications now tell you why

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.

Assign a closed conversation without reopening it

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.