Docs
API reference Getting started Changelog

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 a binding to render. It runs the same validation as POST /api/templates/import, adds lints for emails converted from another platform, and returns { valid, errors[], warnings[], template, preview } with a fix on every issue. Always 200; 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) and INVALID_ITEM_REFERENCE ({{item:x.y}} or a filter on a section row field). .size, .first and .last on 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}/import now 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 address active, 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 of sonar_. The rest of the format is unchanged: 64 lowercase hex characters after the prefix. Existing sonar_ keys and older unprefixed keys keep working. If your code checks a key’s shape, accept radar_ 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, with 409 SEGMENT_IN_USE. error.details.usage names 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}/usage reports two more sources, sequences and forms, and automations now also counts the field-change trigger. Deleting a field those reference returns 409 FIELD_IN_USE, as it already did for segments.

Server-side identify

  • POST /api/contacts and PUT /api/contacts accept session_id — the visitor’s _emts tracking 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-side identify(). The response carries session_link (linked / already_linked, or failed when the contact was written but the link could not be made — the call still succeeds; repeat it with PUT). "session_id": null is the same as leaving it out.
  • A session_id the tracking script did not issue for this account is refused with 400 INVALID_SESSION_ID, before anything is written.
  • stage_id_if_new now also applies when the upsert activates a pending contact that has no stage (one a browser identify() 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) and POST /api/seed-lists/{id}/seeds/changes (add or remove addresses), with the new scopes seed-lists:read and seed-lists:write. Both writes accept dry_run=true, refuse an update that would leave fewer than half the list with 409 LARGE_CHANGE unless allow_large_change=true, and are safe to retry. See Seed lists.
  • PUT /api/seed-lists/{id}/seeds and POST /api/seed-lists/{id}/seeds/changes refuse a list connected to its vendor in Settings (Warmy) with 409 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). With include, 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/stages gains is_default — the stage a contact lands in when nothing else names one.
  • A contact created without stage_id or stage_id_if_new now gets the account’s default stage. It is applied when the contact becomes active, so a pending contact (an anonymous identify(), an unconfirmed double opt-in) still reports stage_id: null until it is activated.
  • Breaking: a stage_id that does not name a stage this account owns is now refused with 400 INVALID_FIELD_VALUE on POST /api/contacts, PUT /api/contacts, PUT /api/contacts/:id and the bulk stage actions. A deleted stage previously surfaced as an unhandled 500 (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/bulk with action: "update_field", field: "stage_id" and value: null is now rejected with INVALID_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.