Skip to content
CaptiFi Connect

Developers

The CaptiFi Connect API

A REST API over HTTPS that takes and returns JSON. It covers what the dashboard does with messages and contacts, plus lists, templates, campaigns, opt-outs, sender names and webhooks.

Base URL

Every path on this page is relative to:

https://captificonnect.co.uk/api/v1

The OpenAPI 3.1 document is at /api/v1/openapi.json. Anyone can read it without a key, so you can point your own tools at it. The API reference is built from the same document, and the quickstart gets you to a first text in a few minutes.

Authentication

Send an API key as a bearer token on every request:

Authorization: Bearer cc_live_...

You create keys in the dashboard under Developers, then API keys. We show a key once, when you create it, and only keep a hash after that, so put it somewhere safe. If a key leaks, revoke it and make a new one.

Each key can make 20 requests a second, with bursts up to 40. Go over that and you'll get a 429 with a Retry-After header telling you when to try again.

Test keys

Keys come in two kinds. Live keys start cc_live_ and send real texts. Test keys start cc_test_, and they send nothing and charge nothing.

A test send is accepted just like a live one. A few seconds later it's marked delivered and your webhooks fire as they would for a real text, so you can build the whole flow before you spend a credit. Responses from a test key have meta.test set to true.

Test keys work while your account is still waiting to be checked, so there's no need to hang around before you start building.

Responses and errors

A successful response puts its result in data, with anything extra (a cursor, a balance) in meta:

{
  "data": { ... },
  "meta": { ... }
}

Errors come back with a 4xx or 5xx status and an error object. The code is stable, so that's the thing to branch on; message is written for people, and details carries specifics when there are any.

{
  "error": {
    "code": "insufficient_credit",
    "message": "This send needs 12 credits and the balance is 3.",
    "details": { "needed": 12, "balance": 3 }
  }
}
StatusWhat it means
401The key is missing, wrong or revoked.
402Not enough credit. details.needed and details.balance say by how much.
403The account is still waiting to be checked (account_pending) or has been suspended (account_suspended).
404No such resource in your account. Another account's ids read as not found too.
409A conflict, such as an Idempotency-Key reused with a different body, or cancelling a text that's already on its way.
422The request isn't valid. details lists the fields.
429Over the rate limit. Wait for the time in the Retry-After header.

Pagination

Lists come back newest first, 25 to a page, or up to 100 if you ask with limit. When there's another page, meta.next_cursor holds a cursor; pass it back as cursor to fetch it. A null next_cursor means you've reached the end.

GET /messages?limit=50

{
  "data": [ ... ],
  "meta": { "next_cursor": "msg_01J9Z8X7W6V5T4S3R2Q1P0N9M8" }
}

GET /messages?limit=50&cursor=msg_01J9Z8X7W6V5T4S3R2Q1P0N9M8

The opt-outs list is the one exception. It takes limit and offset and returns a total in meta.

Sending a text

POST /messages sends one text to everyone you name in to, contact_ids and list_id. We check the numbers and take out duplicates first. Anything we can't send to is listed in rejected with a reason and isn't charged. Credits come off when the request is accepted, one per SMS part, and any text that fails before it leaves us is refunded.

Request
curl https://captificonnect.co.uk/api/v1/messages \
  -H "Authorization: Bearer $CONNECT_API_KEY" \
  -H "Idempotency-Key: booking-1042-reminder" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["07412 000001"],
    "body": "Crown Dental: your check-up is on Thu 19 Nov at 10:30. Reply C to confirm.",
    "client_ref": "booking-1042"
  }'
Response (trimmed)
HTTP/1.1 201 Created

{
  "data": {
    "batch_id": "bat_01J9Z8X7W6V5T4S3R2Q1P0N9M7",
    "messages": [
      {
        "id": "msg_01J9Z8X7W6V5T4S3R2Q1P0N9M8",
        "direction": "outbound",
        "status": "queued",
        "to": "+447412000001",
        "encoding": "gsm7",
        "segments": 1,
        "credits": 1,
        "client_ref": "booking-1042",
        "test": false
      }
    ],
    "rejected": []
  },
  "meta": { "credits": 1, "balance": 1239, "replayed": false, "test": false }
}
to
UK numbers in any common format, as a string or an array of up to 1,000. A string can hold several numbers separated by commas or new lines.
contact_ids, list_id
Saved contacts or a whole list. You can use them alongside to; everyone named across the three gets the text once.
body
Up to 1,600 characters (10 SMS parts). Merge fields {first_name}, {last_name}, {full_name}, {phone} and {email} fill in from contacts.
template_id
A saved template, used when body is empty.
sender
An approved sender name. Leave it out to send from a number people can reply to.
schedule_at
An ISO 8601 date and time, up to a year ahead.
client_ref
Your own reference, up to 64 characters. It comes back to you in webhooks.
append_opt_out
Set to true to add your account's opt-out line, for marketing texts.
track_links
Set to true to swap each link for a short tracked one, unique to each recipient.

The reasons you might see in rejected are invalid_number, unsupported_destination, not_mobile, duplicate, opted_out, too_long and empty_body.

To see what a send would cost without sending it, post the same body to /messages/quote.

Idempotency

Connections drop and requests get retried. Send an Idempotency-Key header (up to 200 characters) with POST /messages and a retry can't send the same texts twice.

If a key comes in again with the same body, you get the first answer back with status 200 and meta.replayed set to true, and nothing new is sent. The same key with a different body is refused with 409 and the code idempotency_conflict. Keys belong to your account, so they can't clash with anyone else's.

Use something tied to the event in your own system, such as an order or booking id, so every retry carries the same key.

Webhooks

Add webhook endpoints in the dashboard or through the API, and pick the events each one gets:

message.sent
A text has left for the network.
message.delivered
The handset received a text.
message.failed
A text failed or wasn't delivered.
message.received
Someone texted you.
opt_out.created
A number texted STOP.
link.clicked
Someone tapped a tracked link in a text.

Each delivery is a JSON POST with the event's id, type, created_at and data. Answer with any 2xx status within 8 seconds. If you don't, we try again after 1, 5, 30, 120, 360 and 720 minutes, and then stop.

X-Connect-Event carries the event type. X-Connect-Delivery stays the same on every retry, so you can use it to ignore repeats, and X-Connect-Signature lets you check the request came from us.

An event as it arrives
POST https://example.com/connect/events
X-Connect-Event: message.delivered
X-Connect-Delivery: whd_01J9Z8X7W6V5T4S3R2Q1P0N9MA
X-Connect-Signature: t=1760605206,v1=5f2b...

{
  "id": "evt_01J9Z8X7W6V5T4S3R2Q1P0N9M9",
  "type": "message.delivered",
  "created_at": "2026-10-16T09:00:06.000Z",
  "data": { "id": "msg_01J9Z8X7W6V5T4S3R2Q1P0N9M8", "status": "delivered", ... }
}

Webhook URLs have to be on the public internet. We refuse private, loopback and link-local addresses, and we don't follow redirects. In the dashboard each endpoint has a test button and a log of recent deliveries.

Checking signatures

The signature header looks like t=1760605206,v1=5f2b.... Here t is a Unix timestamp in seconds, and v1 is the hex HMAC SHA-256 of the timestamp, a full stop and the raw request body, keyed with your webhook secret.

Work out the same value yourself, compare the two in constant time, and reject anything with a timestamp more than five minutes old. Use the body exactly as it arrived, before any JSON parsing. In Node it looks like this:

verify.js
import crypto from 'node:crypto';

// rawBody is the request body exactly as it arrived, before JSON parsing.
export function isFromConnect(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false; // older than five minutes

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The secret is shown once, when you create the webhook. Rotating it gives you a new one, also shown once.

Endpoints

Paths are relative to the base URL. The OpenAPI document has every parameter and field.

Messages
POST /messagesSend texts
POST /messages/quoteWork out parts and credits without sending
GET /messagesList texts, with filters
GET /messages/{id}One text with its status timeline
POST /messages/{id}/cancelCancel a scheduled text and refund it
GET /messages/exportExport texts as CSV
Conversations
GET /conversationsInbox threads, newest first, with unread counts
GET /conversations/{phone}One thread's texts
Contacts and lists
GET, POST /contactsList and search contacts, or create one
GET, PATCH, DELETE /contacts/{id}Read, update or delete a contact
POST /contacts/importImport up to 20,000 rows, optionally into a list
GET /contacts/fieldsThe custom field names in use, for merge fields
GET, POST /listsList or create lists
GET, PATCH, DELETE /lists/{id}Read, rename or delete a list
GET, POST /lists/{id}/contactsList members or add them
DELETE /lists/{id}/contacts/{contactId}Remove a member
Templates and campaigns
GET, POST /templatesList or create templates
GET, PATCH, DELETE /templates/{id}Read, update or delete a template
GET, POST /campaignsList campaigns or create a draft
GET, PATCH, DELETE /campaigns/{id}Read, edit or delete a draft
POST /campaigns/{id}/sendSend a draft now or schedule it
POST /campaigns/{id}/cancelCancel a scheduled or sending campaign
Keywords and links
GET, POST /keywordsList keywords or add one, with its reply and list
GET, PATCH, DELETE /keywords/{id}Read, change or delete a keyword
GET /linksTracked links with their click counts, by campaign or text
GET /links/{id}One tracked link
Opt-outs, sender names and numbers
GET, POST /opt-outsList opt-outs or add one
DELETE /opt-outs/{phone}Remove an opt-out (only when the person asks)
GET, POST /sender-idsList sender names or ask for one
GET /numbersThe dedicated numbers assigned to your account
Webhooks
GET, POST /webhooksList or create webhook endpoints
PATCH, DELETE /webhooks/{id}Update, rotate the secret or delete
POST /webhooks/{id}/testSend a test event now
GET /webhooks/{id}/deliveriesRecent deliveries
Account
GET /balanceCredit balance and account status
GET /stats/summaryTotals for a period
GET /stats/timeseriesDaily counts for a period
GET /openapi.jsonThe OpenAPI document

Get a test key

Create an account and you can make a test key straight away, before we've checked the account. If you get stuck, email support@captificonnect.co.uk.