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/v1The 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 }
}
}| Status | What it means |
|---|---|
| 401 | The key is missing, wrong or revoked. |
| 402 | Not enough credit. details.needed and details.balance say by how much. |
| 403 | The account is still waiting to be checked (account_pending) or has been suspended (account_suspended). |
| 404 | No such resource in your account. Another account's ids read as not found too. |
| 409 | A conflict, such as an Idempotency-Key reused with a different body, or cancelling a text that's already on its way. |
| 422 | The request isn't valid. details lists the fields. |
| 429 | Over 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_01J9Z8X7W6V5T4S3R2Q1P0N9M8The 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.
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"
}'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
bodyis 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.
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:
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 /messages | Send texts |
| POST /messages/quote | Work out parts and credits without sending |
| GET /messages | List texts, with filters |
| GET /messages/{id} | One text with its status timeline |
| POST /messages/{id}/cancel | Cancel a scheduled text and refund it |
| GET /messages/export | Export texts as CSV |
| Conversations | |
| GET /conversations | Inbox threads, newest first, with unread counts |
| GET /conversations/{phone} | One thread's texts |
| Contacts and lists | |
| GET, POST /contacts | List and search contacts, or create one |
| GET, PATCH, DELETE /contacts/{id} | Read, update or delete a contact |
| POST /contacts/import | Import up to 20,000 rows, optionally into a list |
| GET /contacts/fields | The custom field names in use, for merge fields |
| GET, POST /lists | List or create lists |
| GET, PATCH, DELETE /lists/{id} | Read, rename or delete a list |
| GET, POST /lists/{id}/contacts | List members or add them |
| DELETE /lists/{id}/contacts/{contactId} | Remove a member |
| Templates and campaigns | |
| GET, POST /templates | List or create templates |
| GET, PATCH, DELETE /templates/{id} | Read, update or delete a template |
| GET, POST /campaigns | List campaigns or create a draft |
| GET, PATCH, DELETE /campaigns/{id} | Read, edit or delete a draft |
| POST /campaigns/{id}/send | Send a draft now or schedule it |
| POST /campaigns/{id}/cancel | Cancel a scheduled or sending campaign |
| Keywords and links | |
| GET, POST /keywords | List keywords or add one, with its reply and list |
| GET, PATCH, DELETE /keywords/{id} | Read, change or delete a keyword |
| GET /links | Tracked links with their click counts, by campaign or text |
| GET /links/{id} | One tracked link |
| Opt-outs, sender names and numbers | |
| GET, POST /opt-outs | List opt-outs or add one |
| DELETE /opt-outs/{phone} | Remove an opt-out (only when the person asks) |
| GET, POST /sender-ids | List sender names or ask for one |
| GET /numbers | The dedicated numbers assigned to your account |
| Webhooks | |
| GET, POST /webhooks | List or create webhook endpoints |
| PATCH, DELETE /webhooks/{id} | Update, rotate the secret or delete |
| POST /webhooks/{id}/test | Send a test event now |
| GET /webhooks/{id}/deliveries | Recent deliveries |
| Account | |
| GET /balance | Credit balance and account status |
| GET /stats/summary | Totals for a period |
| GET /stats/timeseries | Daily counts for a period |
| GET /openapi.json | The 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.