Skip to content
CaptiFi Connect

Developer docs

API changelog and versioning

What changed in the API and when, newest first. Under each date, changes to how existing calls behave come first, since those are the ones that can need a change on your side.

How the API changes

Version 1 of the API lives at /api/v1. We add to it without notice: new fields in responses, new optional fields in requests, new endpoints and webhook events, and new values in fields that already list several, such as a rejection reason or an error code. Write your code to ignore fields and events it doesn't know, and to handle a value it hasn't seen as it would the nearest one it has.

Anything that could break an integration written that way is announced on this page and emailed to every account owner before it happens. That covers removing or renaming a field, changing what a field means or its default, making an optional field required, and lowering a limit.

7 November 2026

Changes to how existing calls behave

  • Test keys can no longer read live data. Contacts, lists, opt-outs, conversations, tracked link clicks and live texts answer 403 with test_key_live_data, and so does a send or quote that names contact_ids or a list_id. GET /messages with a test key lists only the test texts that key sent, and test texts no longer fill merge fields from your contacts. See test keys.
  • A webhook created with a test key now receives events about test texts only (test_only is true), and a test key sees and changes only those webhooks. Webhooks that already existed keep receiving every event; manage them with a live key or in the dashboard.
  • A send to a list through the API ends with your opt-out line, as a campaign always has. Send append_opt_out: false to leave it off. Sends to typed numbers or contact_ids are unchanged, and the quote follows the same rule.
  • A message's error_code now comes from a fixed list, in the API, in webhooks and in exports. A text waiting to be tried again shows retrying where it used to show the route's own code (such as gateway_timeout), refusals and delivery failures map to the documented codes, and a plain cancel has no error code. The list, with whether each code is final and refunded, is on errors.
  • An Idempotency-Key is kept for 24 hours. A retry within that time still gets the first answer; after it, the same key starts a new send.
  • Webhook deliveries, with their payloads, are deleted after 30 days, so GET /webhooks/{id}/deliveries shows the last 30 days and an older delivery can't be sent again.
  • GET /opt-outs pages by cursor like every other list: newest first, 25 to a page, with meta.next_cursor and a new since filter. A request that passes offset still gets the old answer (50 to a page, with meta.total) for now. We'll announce it here before that stops working.
  • For 24 hours after you rotate a webhook secret, X-Connect-Signature carries two v1 values, the old secret's first and the new one's last. Code that reads only the last v1 carries on working with the new secret; checking every v1 lets servers still on the old secret accept deliveries too. The webhooks guide has updated examples.
  • An endpoint that fails every delivery for three days is switched off, its waiting deliveries are marked failed and the account owner gets an email. Until now it was switched off after 100 failures in a row. Webhooks show failing_since and auto_disabled_at.
  • POST /messages answers a body that isn't valid JSON with 400 invalid_json, like every other route. It used to answer 422 validation_failed.
  • opt_out.created fires whenever a number joins your opt-out list: STOP, the opt-out link, the dashboard, the API or an import, with data.source saying which. It used to fire for STOP and the opt-out link only.
  • A reply of six words or fewer that starts with STOP, STOPALL, UNSUBSCRIBE, QUIT or OPTOUT now opts the number out, so "Stop please" counts. Longer texts, and short ones starting with CANCEL or END, still need to be the opt-out word on its own.
  • A contact's custom values are checked against your custom fields. A date field takes YYYY-MM-DD or DD/MM/YYYY and is stored as YYYY-MM-DD, a number field takes a number, and a value that doesn't fit answers 422 validation_failed with the field's key in details.field. A key that isn't a field yet is added as a text field while the account has fewer than 20 fields, so existing integrations keep working; past that it is refused.
  • GET /contacts/fields lists the keys of your custom fields, oldest field first. Keys your contacts already held are added to the list as text fields the first time it is read.
  • GET /contacts/export has one more column per custom field after updated_at, headed by the field's key. The custom JSON column is still there, in the same place.
  • A quote with source: "campaign" now prices the from number you give it, since a campaign can be sent from one.

New

  • API keys can be limited to a scope: full, send (send, bulk send, quote and look up, and read or cancel only the texts and batches it sent) or read (GET only). They can also have an expiry date and a list of up to 20 IP addresses or CIDR ranges. Requests outside those limits answer insufficient_scope, key_expired or ip_not_allowed. Existing keys are full, with no expiry and no address limit.
  • POST /messages/bulk sends up to 1,000 texts in one call, each with its own number, body, client_ref and merge values. Every item is checked on its own and reported in data.results; one Idempotency-Key covers the call.
  • GET /batches/{id} reads a send with its texts counted by status, and POST /batches/{id}/cancel cancels and refunds everything in it that hasn't left yet.
  • A send to more than 1,000 recipients with Prefer: respond-async (or async: true) answers 202 at once with its batch and goes out in the background. Follow it with GET /batches/{id}.
  • expires_in on sends and bulk sends, from 60 to 86,400 seconds: a text that hasn't left by then is cancelled with expired_before_send and refunded, which suits one-time codes.
  • POST /lookup checks a number for free: E.164, whether it's valid, its region, its line type by prefix and whether it has opted out of your texts. No network is asked.
  • smart_encoding: true on sends and quotes swaps curly quotes, en and em dashes, the ellipsis and special spaces for their plain forms before counting, so one pasted apostrophe no longer doubles the parts. The quote now lists non_gsm_characters, the characters that made a text Unicode.
  • Test numbers: with a test key, 07700 900002 comes back undelivered, 07700 900003 replies and 07700 900004 replies STOP, with the webhook events each would raise.
  • DELETE /messages/{id} removes a sent text's body for good while keeping its status and cost. POST /erasures erases a number from your account for someone who has asked: contact, text bodies, link clicks and unsent texts, with a number that had opted out kept only as a hash so it is still never texted. An account can also have a retention period, from 30 to 3,650 days, after which text bodies are removed every day.
  • message.cancelled and opt_out.deleted webhook events, and POST /webhooks/{id}/deliveries/{delivery_id}/retry to send a failed delivery again.
  • Every response carries an X-Request-Id header, and a 500 repeats it as error.request_id for you to quote to support.
  • A Postman collection, built from the OpenAPI document. The document now gives every operation an operationId, types every message field, and lists what it was missing: GET /webhooks/{id}, DELETE /sender-ids/{id}, track_links, ignore_quiet_hours on a campaign send, the number filter on GET /messages and segments_per_recipient in the quote. Errors lists every error code the API returns.
  • GET /campaigns/{id}/report reports on one campaign: its texts by status, the failed and undelivered ones grouped by error code with what each code means, how many people tapped a tracked link, and how many of the people it reached replied or opted out in the seven days after it started, each person counted once.
  • POST /contacts/bulk-delete deletes up to 1,000 contacts in one call and one transaction. It answers with how many were deleted and how many ids weren't found; an id that isn't one of your contacts doesn't stop the rest.
  • since and until on GET /messages and GET /messages/export take a plain date such as 2026-11-01 as well as a date-time. A date covers the whole of that day in UK time, so until=2026-11-07 runs to midnight at the end of 7 November.
  • Custom fields: GET and POST /custom-fields, PATCH /custom-fields/{id} to rename one and DELETE to remove it with its values. An account can have up to 20, each text, date or number. Each works as a merge field by its key, and a date field reads as 14 March 2026 in the text.
  • Segments, saved filters worked out each time they are used: list membership, a custom field (including a date that is today or within the next few days, every year for birthdays), added, replied or clicked a link in the last so many days, and never texted. Opted-out numbers are never in one. /segments has the usual create, read, update and delete, plus GET /segments/{id}/count, GET /segments/{id}/contacts and POST /segments/preview for conditions you haven't saved. Send to one with segment_id on POST /messages, the quote and campaigns.
  • Campaigns take segment_id in place of list_id, and from, one of the numbers GET /numbers/from lists, in place of a sender name.
  • Recurring campaigns at /campaign-series: weekly on chosen days or monthly on a day of the month, at a UK time, with an optional end date or number of runs. Each run is a campaign of its own with series_id set and its own report (GET /campaigns?series_id= lists them). A run that can't go for lack of credit pauses the series. POST to /pause, /resume or /stop on a series to change it.
  • Date-based reminders at /automations: a text some days before, on or after each contact's date field at a set time, to all contacts, a list or a segment, optionally every year. They send like any other send, quiet hours and opt-outs included, and nobody gets one reminder's text twice for the same date. GET /automations/{id}/preview says when it runs next and for how many people; GET /automations/{id}/sends is its log.
  • Three new error codes: field_exists, in_use (a segment or reminder still uses the field or segment you're deleting) and series_ended. Test keys can't read segments, custom fields or reminders, as with contacts.

Fixes

  • A path under /api/v1 that doesn't exist answers 404 not_found as JSON, where it used to return the website's HTML page.
  • GET /conversations?unread=true pages correctly: every page and its cursor count unread threads only.
  • Conversation paths find a thread however the number is written: %2B447412000001, 447412000001 and 07412000001 are the same thread.