Changelog
Every change to the external API surface. The rules governing what may appear here are in Versioning & compatibility.
Entries are added in the same change that makes them true. A changelog written afterwards is a changelog with holes in it.
Unreleased#
Template check (dry run)
- New:
POST /api/templates/check(templates:read). Send a template in the markdown import format as{ "markdown": … }, optionally with abindingto render. It runs the same validation asPOST /api/templates/import, adds lints for emails converted from another platform, and returns{ valid, errors[], warnings[], template, preview }with afixon every issue. Always200; nothing is saved. - Template and block saves (JSON and markdown import) now reject two
spellings that previously saved but could never render correctly, with
400:INVALID_PARAM_PATH({{ param.hero.url }}— a parameter is used whole) andINVALID_ITEM_REFERENCE({{item:x.y}}or a filter on a section row field)..size,.firstand.laston a parameter remain valid. Only a body the request rewrites is checked, so existing content never blocks a rename. - Fixed:
{{ unsubscribe_url }}and{{ view_in_browser_url }}written with spaces (Liquid’s usual spelling) now become working links. They used to be replaced only when written exactly{{unsubscribe_url}}, so the spaced form sent an empty link — in templates and in HTML/MJML broadcasts. - Fixed:
PUT /api/templates/{id}/importnow clears a body the file omits. An HTML file replacing an MJML template used to keep the old MJML, which kept sending.
Seed lists: which bounces retire a seed
- A seed address is now retired (
status: "retired",status_reason: "hard_bounce") only when a bounce says its mailbox no longer exists: an unknown user or a domain that has been retired. A temporary DNS or routing failure doesn’t retire it. A bounce where the provider refused the mail (a block, a blocklist or a policy rejection) leaves the addressactive, even when the sending service labels it a hard bounce. Previously every hard bounce retired the address, so a block retired mailboxes that were still there. See Seed lists.
API key prefix
- New keys start with
radar_instead ofsonar_. The rest of the format is unchanged: 64 lowercase hex characters after the prefix. Existingsonar_keys and older unprefixed keys keep working. If your code checks a key’s shape, acceptradar_too. See Authentication.
Deleting a list or segment that is in use
DELETE /api/segments/{segment_key}now refuses a list or segment that something still depends on, with409 SEGMENT_IN_USE.error.details.usagenames what holds it: broadcasts not yet finished that include or exclude it, non-archived sequences, automations, non-archived forms that add submitters to it, and other segments built on it. Previously the delete went through and the list silently left those audiences and steps.
Custom field usage
GET /api/settings/custom-fields/{id}/usagereports two more sources,sequencesandforms, andautomationsnow also counts the field-change trigger. Deleting a field those reference returns409 FIELD_IN_USE, as it already did for segments.
Server-side identify
POST /api/contactsandPUT /api/contactsacceptsession_id— the visitor’s_emtstracking cookie, passed through by your server. The same call links that browser to the contact: its earlier anonymous page views are attributed, and so is everything after, with no client-sideidentify(). The response carriessession_link(linked/already_linked, orfailedwhen the contact was written but the link could not be made — the call still succeeds; repeat it withPUT)."session_id": nullis the same as leaving it out.- A
session_idthe tracking script did not issue for this account is refused with400 INVALID_SESSION_ID, before anything is written. stage_id_if_newnow also applies when the upsert activates a pending contact that has no stage (one a browseridentify()created before your server’s call). Previously it was ignored there and the contact took the workspace’s default stage.
Seed lists
- New:
GET /api/seed-lists,GET /api/seed-lists/{id}/seeds,PUT /api/seed-lists/{id}/seeds(replace a list’s addresses) andPOST /api/seed-lists/{id}/seeds/changes(add or remove addresses), with the new scopesseed-lists:readandseed-lists:write. Both writes acceptdry_run=true, refuse an update that would leave fewer than half the list with409 LARGE_CHANGEunlessallow_large_change=true, and are safe to retry. See Seed lists. PUT /api/seed-lists/{id}/seedsandPOST /api/seed-lists/{id}/seeds/changesrefuse a list connected to its vendor in Settings (Warmy) with409 VENDOR_MANAGED: such a list updates itself from the vendor, and a change made through the API would be undone by the next update.- New: broadcasts accept and return
seed_mode(none|include). Withinclude, the seed lists attached to the broadcast’s senders get one copy each, sent alongside the broadcast and kept out of its counts and reports.
Contact stages
GET /api/settings/stagesgainsis_default— the stage a contact lands in when nothing else names one.- A contact created without
stage_idorstage_id_if_newnow gets the account’s default stage. It is applied when the contact becomes active, so apendingcontact (an anonymousidentify(), an unconfirmed double opt-in) still reportsstage_id: nulluntil it is activated. - Breaking: a
stage_idthat does not name a stage this account owns is now refused with400 INVALID_FIELD_VALUEonPOST /api/contacts,PUT /api/contacts,PUT /api/contacts/:idand the bulk stage actions. A deleted stage previously surfaced as an unhandled500(a foreign-key violation), which is indistinguishable from an outage and so invited clients to retry a permanent error. A stage belonging to another account previously succeeded silently — the foreign key only checks that the id exists. Both now report the same code. - Breaking:
POST /api/contacts/bulkwithaction: "update_field",field: "stage_id"andvalue: nullis now rejected withINVALID_FIELD. It previously cleared the stage. Every active contact has a stage; move them to a different one instead. - Bulk stage changes now apply the customer/prospect score reset that
single-contact updates already applied.
change_stage,update_field, CSV import and contact merge previously skipped it.
1.0.0#
The first release, and the point at which the compatibility contract starts applying. Everything in this reference is covered by it from here on: an endpoint will not be removed, a field will not be renamed or retyped, and a break — if one is ever genuinely necessary — ships as v2 on its own prefix rather than as a change to this surface.
There is nothing else in this entry, because there is nothing before it. The next entry will describe a real change.