On this page
The Title REST API is the plain-HTTP way into a Title brand: the same contacts, messages, flows and analytics the app shows, reachable from Zapier, Make, n8n, a CRM, or any code that can make an HTTPS request. Title also sends events back to you through signed webhooks.
Base URL: https://titlebm.com/api/v1
Every request and response is JSON. Phone numbers are E.164 (+12125551234). Timestamps are ISO 8601 in UTC.
If you are connecting an AI agent rather than a system, you may want the MCP server (for a model that works with your account) or Agent Connect (for an agent that talks to your customers). Both run on the same platform as this API.
Authentication#
Every request sends your API key as a bearer token:
export TITLE_API_KEY="titlek_live_..."
curl "https://titlebm.com/api/v1/flows" \
-H "Authorization: Bearer $TITLE_API_KEY"
Keys are scoped to one brand. Everything a key reads or writes belongs to that brand, and a key can never see another brand's data, even inside the same organization. If you work with several brands, you need one key per brand.
Key format: titlek_<env>_<32 characters>, where <env> is live or test. Only the SHA-256 hash of a key is stored, so a key is shown exactly once when it is issued. Keep it in a secrets manager or your environment variables, never in client-side code.
Getting a key#
There is no self-serve key page yet. Email admin@titlebm.com with the brand you want to connect and what you are building, and we will issue a key for that brand. To revoke a key, or to rotate one, email the same address.
Scopes#
| Key | Scope | Can call |
|---|---|---|
| API key issued by Title | * | Every endpoint in this reference. /api/v1/agent/* answers 403 insufficient_scope. |
| Agent Connect key (created in Settings > Agent Connect) | agent:<connection_id> | Only /api/v1/agent/*. Every other endpoint answers 403 insufficient_scope. |
There are no finer-grained read or write scopes today.
Authentication errors#
| Status | error | When |
|---|---|---|
401 | unauthorized | No Authorization: Bearer header (the response then carries WWW-Authenticate: Bearer), or the key is unknown or revoked. |
403 | insufficient_scope | The key's scope does not cover this endpoint (see the table above). |
Errors#
Every error is JSON with two fields:
{ "error": "INVALID_PARAMS", "message": "phone is required (E.164)" }
| Status | error | Meaning |
|---|---|---|
400 | INVALID_PARAMS | A required field is missing, the body is not valid JSON, or a value is out of range. The message says which. |
401 | unauthorized | See Authentication. |
402 | INSUFFICIENT_CREDITS | The brand's credit balance is too low to send. Nothing was sent. |
403 | insufficient_scope | See Authentication. |
404 | NOT_FOUND | The contact, flow, subscription or delivery does not exist in this brand. |
409 | CONFLICT | The send cannot happen right now: no RCS agent is connected to the brand, or the brand enforces quiet hours and it is outside sending hours for this contact. The message explains which. |
429 | RATE_LIMITED | Not returned by these endpoints today. Reserved, so handle it like any other error. |
500 | INTERNAL | Something failed on Title's side, including a failed hand-off to the messaging provider. Safe to retry. |
POST /messages can also answer with the idempotency codes invalid_idempotency_key (400), idempotency_key_reused (422) and idempotency_in_progress (409); see Idempotency.
The Agent Connect endpoints use their own lowercase codes, listed in the Agent Connect guide.
Rate limits and pagination#
There is no fixed request quota on these endpoints today. Sends are limited by the brand's credit balance and by the messaging provider. If a limit is introduced it will answer 429 with a Retry-After header, so handling that status now is a good idea.
Outbound webhooks are delivered with at most 5 deliveries in flight at a time across the platform, so a burst of events reaches you over a few seconds rather than all at once.
List endpoints do not use cursors. Each takes a limit query parameter and returns the newest rows first; the limits are in each endpoint's section.
Idempotency#
POST /messages accepts an optional Idempotency-Key header (up to 255 characters). Keys are scoped to your API key and remembered for 24 hours, so a retried request never sends a second message:
| Situation | Result |
|---|---|
| Same key, same body | The first response is replayed with the header Idempotent-Replayed: true. Nothing is sent again. |
| Same key, different body | 422 {"error":"idempotency_key_reused"} |
| Same key while the first request is still running | 409 {"error":"idempotency_in_progress"}. Retry shortly. |
| Key longer than 255 characters | 400 {"error":"invalid_idempotency_key"} |
First attempt ended in 5xx, 429 or 402 | Not stored. Retry with the same key after fixing the cause (for example, after adding credits). |
curl -X POST "https://titlebm.com/api/v1/messages" \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-18212-confirmation" \
-d '{"phone":"+12125551234","intent":{"type":"send_text","text":"Your order is on its way."}}'
Contacts#
A contact is a phone number inside a brand, with a name, email, consent state, tags, list memberships and free-form custom fields.
The contact record returned by every contacts endpoint:
{
"id": "5e8a1f20-...",
"name": "Sam Lee",
"first_name": "Sam",
"last_name": "Lee",
"phone": "+12125551234",
"email": "sam@example.com",
"consent": "opted_in",
"tags": ["vip", "newsletter"],
"custom_fields": { "plan": "pro" },
"rcs_enabled": true,
"last_contact_date": "2026-09-30T15:12:08.000Z",
"created_at": "2026-06-01T18:30:42.000Z",
"sublists": [{ "id": "9b0c...", "name": "Newsletter" }]
}
consent is one of opted_in, opted_out or unknown. rcs_enabled says whether the number is known to receive RCS.
Create or update a contact#
POST /contacts
Creates the contact, or updates it when the phone number already exists in this brand. Fields you leave out are left alone; custom_fields are merged key by key.
| Field | Type | Notes |
|---|---|---|
phone | string | Required. E.164. US numbers in other formats, such as (212) 555-1234, are normalized; anything ambiguous is rejected with 400. |
first_name, last_name, name | string | Optional. |
email | string | Optional. |
consent | opted_in, opted_out or unknown | Optional, default unknown. Only pass opted_in when your system collected explicit consent (a form checkbox, for example). Title cannot grant consent for you. |
tags | string[] | Optional. Replaces the contact's tags when the contact already exists. |
custom_fields | object | Optional. Merged into the existing fields. |
Response 201 with the contact record. The status is 201 whether the contact was created or updated.
curl -X POST "https://titlebm.com/api/v1/contacts" \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+12125551234",
"first_name": "Sam",
"last_name": "Lee",
"email": "sam@example.com",
"consent": "opted_in",
"tags": ["newsletter"],
"custom_fields": { "plan": "pro" }
}'
Get a contact#
GET /contacts/{id} by id, or GET /contacts/by-phone?phone=+12125551234 by phone number.
Response 200 with the contact record, or 404 NOT_FOUND.
curl "https://titlebm.com/api/v1/contacts/by-phone?phone=%2B12125551234" \
-H "Authorization: Bearer $TITLE_API_KEY"
Update a contact#
PATCH /contacts/{id}
One request can change tags, list memberships, native fields and custom fields, and add a note. At least one of the top-level fields is required, otherwise 400.
| Field | Type | Notes |
|---|---|---|
add_tags | string[] | Tags already on the contact are skipped. |
remove_tags | string[] | |
add_to_lists | string[] | List names, matched case-insensitively. A name that matches no list is ignored. |
fields | object | Any of name, first_name, last_name, email (strings), consent (one of the three values; anything else is ignored) and custom (an object merged into custom_fields). |
add_note | string | Adds an internal note to the contact. |
Response 200:
{
"contact_id": "5e8a1f20-...",
"updated_fields": ["added tags: vip", "updated: email", "added to list: Newsletter", "added note"],
"current_tags": ["newsletter", "vip"]
}
updated_fields is a human-readable list of what changed. 404 NOT_FOUND when the contact is not in this brand.
curl -X PATCH "https://titlebm.com/api/v1/contacts/5e8a1f20-..." \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"add_tags": ["vip"],
"fields": { "email": "sam@example.com", "custom": { "plan": "pro" } },
"add_to_lists": ["Newsletter"]
}'
Messages#
Send a message#
POST /messages
Sends one RCS message to a phone number from the brand's connected RCS agent. The recipient does not have to be a contact yet; if they are, the message is logged in their conversation in the inbox.
| Field | Type | Notes |
|---|---|---|
phone | string | Required. E.164. |
intent | object | Required. A message intent, see Message types below. |
Optional header: Idempotency-Key (see Idempotency).
Response 202:
{
"messageId": "SM8f2e0c1d...",
"status": "sent",
"phone": "+12125551234",
"warnings": [],
"messageSendId": "3c1d...",
"conversationMessageId": "7a90...",
"conversationId": "0d3b...",
"contactId": "5e8a..."
}
messageId is the provider's id for the message. messageSendId is Title's send record. conversationMessageId, conversationId and contactId are null when the number is not a contact in this brand. warnings lists anything the builder could not resolve, for example a media_key that is not in the media library.
Each send is charged to the brand's credits at the same rate as a message sent from the inbox. The balance is checked before sending: when it is too low the request answers 402 INSUFFICIENT_CREDITS and nothing is sent.
Errors:
| Status | error | When |
|---|---|---|
400 | INVALID_PARAMS | phone or intent missing, or the intent fails validation. The message lists every problem, for example Invalid message intent: Button text max 25 chars. |
402 | INSUFFICIENT_CREDITS | Not enough credits. |
409 | CONFLICT | No RCS agent is connected to the brand, or the brand enforces quiet hours and it is outside 8 AM to 9 PM in the contact's local time. The message says which and, for quiet hours, when to try again. |
500 | INTERNAL | The provider rejected the send, or the recipient has opted out of messages from this brand. The message explains. |
Text in the intent can use personalization tokens. {first_name}, {last_name} and {name} are filled from the contact that matches the phone number, and {brand_name} from the brand. Tokens Title cannot fill are left as written.
curl -X POST "https://titlebm.com/api/v1/messages" \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+12125551234",
"intent": {
"type": "send_rich_card",
"card": {
"title": "Your table is booked",
"description": "Saturday at 7:00 PM for 4. Reply if anything changes.",
"media_url": "https://example.com/images/table.jpg",
"buttons": [
{ "type": "calendar", "label": "Add to calendar", "event_title": "Dinner", "start_time": "2026-10-10T19:00:00-04:00", "end_time": "2026-10-10T21:00:00-04:00" },
{ "type": "dial", "label": "Call us", "phone": "+12125550100" }
]
},
"suggestions": [
{ "type": "reply", "label": "Reschedule" },
{ "type": "url", "label": "Directions", "url": "https://maps.example.com/our-place" }
]
}
}'
Message types#
intent.type selects the shape. Validation is strict: a field that does not belong to the shape is an error, not ignored.
type | Fields | Limits |
|---|---|---|
send_text | text, optional suggestions | text 1 to 2,000 characters |
send_rich_card | card, optional suggestions | One card |
send_carousel | cards, optional suggestions | 2 to 10 cards |
send_media | media_url or media_key, optional suggestions | One image or video |
send_booking_prompt | service_id or service_name, optional text, slots ([{start_time, end_time, label?}]), suggestions | text up to 2,000 characters |
A card has title (up to 200 characters), optional description (up to 2,000), optional media_url or media_key (an asset from the brand's media library), and up to 4 buttons.
Buttons (on a card) and suggestions (chips under the message, up to 11) share the same shapes. Every label is at most 25 characters.
type | Fields | What it does |
|---|---|---|
reply | label, optional postback | Sends the label (or postback) back as the customer's reply. |
url | label, url | Opens a link. |
dial | label, phone | Starts a phone call. |
location | label | Asks the customer to share their location. |
calendar | label, event_title, start_time, end_time, optional description | Adds an event to the customer's calendar. Times are ISO 8601. |
Recent sends#
GET /messages
The brand's recent sends with delivery, read, tap and reply timestamps and the cost of each.
| Query | Default | Notes |
|---|---|---|
recent_days | 14 | Window in days. 0 means no window. Negative values are rejected. |
limit | 25 | 1 to 100. |
flow_id | Only sends made by this flow. |
Response 200:
{
"sends": [
{
"message_send_id": "3c1d...",
"contact_id": "5e8a...",
"contact_name": "Sam Lee",
"contact_phone": "+12125551234",
"status": "delivered",
"message_type": "rcs_media",
"sent_at": "2026-09-30T15:12:08.000Z",
"delivered_at": "2026-09-30T15:12:10.000Z",
"read_at": "2026-09-30T15:14:01.000Z",
"first_click_at": null,
"reply_at": null,
"campaign_id": null,
"campaign_name": null,
"flow_id": "c0ffee...",
"flow_name": "Booking confirmation",
"cost_cents": 5
}
],
"totals": { "sent": 1, "delivered": 1, "read": 1, "clicked": 0, "replied": 0, "failed": 0, "cost_cents": 5 },
"as_of": "2026-10-03T12:00:00.000Z",
"recent_days": 14,
"newest_sent_at": "2026-09-30T15:12:08.000Z",
"is_empty_window": false
}
Test sends from the Studio are excluded.
Flows#
A flow is a saved message sequence built in the Studio: one or more rich messages, buttons that branch, and actions such as tagging a contact or waiting for a reply.
List flows#
GET /flows
| Query | Default | Notes |
|---|---|---|
active_only | true | false includes drafts and inactive flows. |
limit | 20 | 1 to 50. |
Response 200, a JSON array ordered by last update, newest first:
[
{
"id": "c0ffee...",
"name": "Booking confirmation",
"description": "Sent after a booking is made",
"status": "draft",
"is_active": true,
"trigger_type": "manual",
"execution_count": 412,
"usage_count": 3,
"created_at": "2026-06-01T18:30:42.000Z",
"updated_at": "2026-09-28T09:00:00.000Z",
"step_count": 4,
"message_count": 3,
"action_count": 1
}
]
This is the call to populate a "pick a flow" dropdown. status is the flow's editing state (draft by default); is_active is whether it is live.
Get a flow#
GET /flows/{id}
Response 200 with everything in the list entry plus trigger_config, node_summary (an array of { id, type, name } where type is message, action, wait_until or unknown) and the full design_data of the flow. 404 NOT_FOUND when the flow is not in this brand.
Trigger a flow#
POST /flows/trigger
Starts a saved flow for one person. Pass either a contact_id or a phone. When the phone number is not a contact in this brand yet, the contact is created first.
| Field | Type | Notes |
|---|---|---|
flow_id | string | Required. |
phone | string | E.164. Required unless contact_id is given. |
contact_id | string | Required unless phone is given. Must belong to this brand. |
first_name, last_name, email | string | Used only when the contact is created. |
consent | opted_in, opted_out or unknown | Used only when the contact is created. Default unknown. |
tags | string[] | Used only when the contact is created. |
initial_context | object | Values the flow can use as variables, for example { "order_number": "18212" }. |
Response 202:
{
"execution_id": "e1f2...",
"flow_id": "c0ffee...",
"contact_phone": "+12125551234",
"status": "active"
}
Errors: 400 INVALID_PARAMS when flow_id is missing or neither phone nor contact_id is given; 404 NOT_FOUND when the flow, or the contact_id, is not in this brand.
curl -X POST "https://titlebm.com/api/v1/flows/trigger" \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"flow_id": "c0ffee...",
"phone": "+12125551234",
"first_name": "Sam",
"consent": "opted_in",
"tags": ["zapier:lead"],
"initial_context": { "order_number": "18212" }
}'
Analytics#
Message stats#
GET /analytics
Sent, delivered, read, tap and reply counts over a date range, with rates.
| Query | Default | Notes |
|---|---|---|
date_from | 30 days ago | ISO 8601. |
date_to | now | ISO 8601. |
flow_id | Only sends made by this flow. |
Response 200:
{
"totals": {
"sent": 1240,
"delivered": 1198,
"read": 1031,
"clicked": 287,
"replied": 142,
"failed": 12,
"delivery_rate": 96.6,
"read_rate": 86.1,
"click_rate": 24,
"reply_rate": 11.5
},
"daily_breakdown": [
{ "date": "2026-09-01", "sent": 40, "delivered": 39, "read": 33, "clicked": 9, "replied": 4 }
],
"filters_applied": {
"flow_id": null,
"campaign_id": null,
"date_from": "2026-09-01T00:00:00.000Z",
"date_to": "2026-10-01T00:00:00.000Z"
}
}
Rates are percentages with one decimal. delivery_rate and reply_rate are out of sent; read_rate and click_rate are out of delivered. daily_breakdown is null when the range is 7 days or shorter, or when there were no sends. An unparseable date answers 400.
Webhooks#
Title can POST events to your HTTPS endpoint as they happen: a customer replied, a message was delivered or read, a contact opted out. Every delivery is signed, retried on failure, and can be inspected and replayed from the API.
Subscribe#
POST /webhooks
| Field | Type | Notes |
|---|---|---|
target_url | string | Required. A public HTTPS URL. Private and loopback addresses are rejected. |
event_types | string[] | Required. One or more of the event types below. |
description | string | Optional. |
metadata | object | Optional. Stored with the subscription and returned as is. |
Response 201:
{
"id": "a7c3...",
"target_url": "https://example.com/title-webhook",
"event_types": ["message.received", "contact.opted_out"],
"signing_secret": "f3b1...64 hex characters...",
"created_at": "2026-10-03T12:00:00.000Z",
"note": "Store signing_secret now. It is not returned again."
}
The signing_secret is shown once. Store it; you need it to verify every delivery. 400 INVALID_PARAMS lists the allowed event types when one is unknown.
curl -X POST "https://titlebm.com/api/v1/webhooks" \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://example.com/title-webhook",
"event_types": ["message.received", "message.delivered", "contact.opted_out"],
"description": "CRM sync"
}'
Event types#
| Event | Fires when |
|---|---|
message.received | A customer replies to the brand by RCS or SMS, or taps a button or chip. Never for STOP, START or HELP. |
message.delivered | An outbound message was delivered. |
message.read | An outbound message was read (RCS read receipt). |
message.failed | An outbound message failed. |
contact.opted_out | A contact texted STOP, or the carrier reported an opt-out. |
contact.opted_in | A contact texted START, or the carrier reported an opt-in. |
conversation.human_took_over | Agent Connect: a teammate took over a conversation from the agent. |
conversation.handed_back | Agent Connect: a conversation was handed back to the agent. |
connection.test | Agent Connect only. Sent to the connection's own endpoint by the "Send test event" button, never to API subscriptions. |
flow.started | Accepted when subscribing, but Title does not emit it yet. |
flow.completed | Accepted when subscribing, but Title does not emit it yet. |
contact.created | Accepted when subscribing, but Title does not emit it yet. |
contact.updated | Accepted when subscribing, but Title does not emit it yet. |
Message and consent events carry deterministic ids (evt_msg_<provider_message_id>, evt_message.delivered_<provider_message_id>, and so on), so a provider retry never produces a second delivery to you.
The envelope#
Every delivery has the same outer shape; data depends on the event type.
{
"version": "v1",
"event_id": "evt_msg_SM8f2e0c1d",
"event_type": "message.received",
"timestamp": "2026-10-03T12:00:00.000Z",
"brand_id": "a1b2c3d4-...",
"data": {
"contact_id": "5e8a...",
"conversation_id": "0d3b...",
"message_id": "9c41...",
"phone": "+12125551234",
"body": "Yes please",
"button": null,
"provider": "pinnacle",
"provider_message_id": "SM8f2e0c1d",
"channel": "rcs",
"received_at": "2026-10-03T12:00:00.000Z"
}
}
data by event type:
| Event | data fields |
|---|---|
message.received | contact_id, conversation_id, message_id, phone, body, button ({ title, payload } for a tap, else null), provider, provider_message_id, channel (rcs or sms), received_at |
message.delivered, message.read, message.failed | provider_message_id, contact_id, phone, status, failure_reason (only set on message.failed), occurred_at |
contact.opted_out, contact.opted_in | contact_id, phone, consent, source (for example keyword), occurred_at |
conversation.human_took_over, conversation.handed_back | conversation_id, source, user_id, reason, occurred_at, agent_connection_id |
webhook.test | message, sent_via, plus anything you passed as data to the test endpoint |
event_id is stable across retries. Use it to de-duplicate on your side: Title re-delivers after timeouts.
Headers Title sends#
Content-Type: application/json
User-Agent: Title-Webhooks/1.0
X-Title-Event-Id: evt_msg_SM8f2e0c1d
X-Title-Event-Type: message.received
X-Title-Timestamp: 1759492800000
X-Title-Signature: sha256=<hex>
X-Title-Timestamp is Unix time in milliseconds.
Verifying the signature#
The signature is HMAC-SHA256 over the string <X-Title-Timestamp>.<raw request body>, keyed with your signing_secret, hex-encoded and prefixed with sha256=. Compute it over the raw bytes before parsing the JSON, and compare in constant time.
const crypto = require('crypto');
function verifyTitleSignature(rawBody, headers, signingSecret) {
const signature = headers['x-title-signature'];
const timestamp = headers['x-title-timestamp'];
if (!signature || !timestamp) return false;
const expected =
'sha256=' + crypto.createHmac('sha256', signingSecret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib
def verify_title_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
To block replays, also reject deliveries whose timestamp is more than a few minutes old:
if (Date.now() - Number(timestamp) > 5 * 60 * 1000) reject();
Respond with any 2xx status within 10 seconds. The response body is ignored (the first 512 characters are kept in the delivery log for debugging).
Retries#
| Your response | What Title does |
|---|---|
2xx | Delivery marked succeeded. |
4xx other than 408 and 429 | Delivery marked failed. No retry; the request is considered rejected by you. |
5xx, 408, 429, a timeout (10 seconds), or a network error | Retried with exponential backoff: about 2, 4, 8 and 16 minutes after each failure, with up to 25% added jitter. |
A delivery gets 5 attempts in total. After the fifth failure it is marked dead. Redirects are not followed.
After 20 consecutive failed attempts on a subscription (across any of its deliveries), the subscription is disabled: is_active becomes false and disabled_at is set. There is no re-enable call; delete the subscription and subscribe again. A successful delivery resets the counter.
Manage subscriptions#
GET /webhooks lists this brand's API subscriptions, newest first (Agent Connect subscriptions are managed from their connection and are not included):
{
"data": [
{
"id": "a7c3...",
"target_url": "https://example.com/title-webhook",
"event_types": ["message.received"],
"description": "CRM sync",
"metadata": {},
"is_active": true,
"disabled_at": null,
"last_delivery_at": "2026-10-03T12:00:04.000Z",
"last_success_at": "2026-10-03T12:00:04.000Z",
"last_failure_at": null,
"consecutive_failures": 0,
"created_at": "2026-10-03T12:00:00.000Z"
}
]
}
GET /webhooks/{id} returns one subscription in the same shape (not wrapped in data). 404 NOT_FOUND when it is not in this brand.
DELETE /webhooks/{id} unsubscribes. The subscription is kept with is_active: false so its delivery history stays readable. Response 200 { "id": "a7c3...", "unsubscribed": true }.
Test a subscription#
POST /webhooks/{id}/test
Sends a synthetic webhook.test event through the real delivery pipeline (signing, retries, delivery log) so you can check your endpoint without texting anyone. The body is optional: { "data": { ... } } is merged into the event's data.
Response 200:
{
"delivery_id": "d4e5...",
"event_id": "evt_test_...",
"queued": true,
"note": "Hit GET /api/v1/webhooks/:id/deliveries to see the result."
}
curl -X POST "https://titlebm.com/api/v1/webhooks/a7c3.../test" \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"data":{"hello":"world"}}'
Delivery log#
GET /webhooks/{id}/deliveries
| Query | Default | Notes |
|---|---|---|
status | all | One of pending, succeeded, failed, dead. |
limit | 50 | 1 to 200. |
Response 200:
{
"data": [
{
"id": "d4e5...",
"event_id": "evt_msg_SM8f2e0c1d",
"event_type": "message.received",
"status": "succeeded",
"attempt": 1,
"max_attempts": 5,
"last_status_code": 200,
"last_error": null,
"last_attempt_at": "2026-10-03T12:00:04.000Z",
"response_snippet": "ok",
"created_at": "2026-10-03T12:00:00.000Z",
"completed_at": "2026-10-03T12:00:04.000Z"
}
]
}
Replay a delivery#
POST /webhooks/{id}/replay/{deliveryId}
Re-sends a past delivery: the attempt counter is reset and the same signed envelope is queued again. Useful after an outage on your side.
Response 200 { "id": "d4e5...", "event_id": "evt_msg_SM8f2e0c1d", "event_type": "message.received", "replayed": true }, or 404 NOT_FOUND.
Agent Connect endpoints#
Agent Connect is for a business that plugs its own AI agent into its text line: Title delivers the messages, the agent decides what to say. Its endpoints live under /api/v1/agent and need an Agent Connect key (scope agent:<connection_id>), created in Settings > Agent Connect.
| Method | Path | What it does |
|---|---|---|
GET | /agent/me | The connection this key belongs to. |
GET | /agent/updates?after=&limit=&wait= | New events after a cursor, with long polling up to 25 seconds. |
POST | /agent/messages | Reply in a conversation, or start one by phone number. |
POST | /agent/typing | Show a typing indicator. |
POST | /agent/handoff | Hand a conversation to the business's team. |
GET | /agent/conversations/{id}/messages | Recent messages in a conversation. |
Setup, the event reference, guardrails (opt-outs, quiet hours, rate limits, human takeover), error codes and the MCP agent mode are all in the Agent Connect guide.
Endpoint reference#
Base URL: https://titlebm.com/api/v1. Every request needs Authorization: Bearer <your key>.
| Method | Path | What it does |
|---|---|---|
POST | /contacts | Create or update a contact by phone number. |
GET | /contacts/{id} | One contact by id. |
GET | /contacts/by-phone?phone= | One contact by phone number. |
PATCH | /contacts/{id} | Tags, lists, fields, custom fields, notes. |
POST | /messages | Send one RCS message. Supports Idempotency-Key. |
GET | /messages | Recent sends with delivery data and cost. |
GET | /flows | List flows. |
GET | /flows/{id} | One flow, including its structure. |
POST | /flows/trigger | Start a flow for a contact or phone number. |
GET | /analytics | Message stats over a date range. |
POST | /webhooks | Subscribe a URL to events. |
GET | /webhooks | List subscriptions. |
GET | /webhooks/{id} | One subscription. |
DELETE | /webhooks/{id} | Unsubscribe. |
POST | /webhooks/{id}/test | Send a test event. |
GET | /webhooks/{id}/deliveries | Delivery log. |
POST | /webhooks/{id}/replay/{deliveryId} | Re-send a delivery. |
/agent/* | Agent Connect, see above. |