Developers

REST API

Create contacts, send RCS messages, trigger flows, read analytics and subscribe to signed webhooks from your own systems. One brand-scoped API key, plain JSON over HTTPS.

  • REST API
  • Webhooks
  • Idempotency
  • API keys

Updated

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:

curl
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#

KeyScopeCan 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#

StatuserrorWhen
401unauthorizedNo Authorization: Bearer header (the response then carries WWW-Authenticate: Bearer), or the key is unknown or revoked.
403insufficient_scopeThe key's scope does not cover this endpoint (see the table above).

Errors#

Every error is JSON with two fields:

JSON
{ "error": "INVALID_PARAMS", "message": "phone is required (E.164)" }
StatuserrorMeaning
400INVALID_PARAMSA required field is missing, the body is not valid JSON, or a value is out of range. The message says which.
401unauthorizedSee Authentication.
402INSUFFICIENT_CREDITSThe brand's credit balance is too low to send. Nothing was sent.
403insufficient_scopeSee Authentication.
404NOT_FOUNDThe contact, flow, subscription or delivery does not exist in this brand.
409CONFLICTThe 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.
429RATE_LIMITEDNot returned by these endpoints today. Reserved, so handle it like any other error.
500INTERNALSomething 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:

SituationResult
Same key, same bodyThe first response is replayed with the header Idempotent-Replayed: true. Nothing is sent again.
Same key, different body422 {"error":"idempotency_key_reused"}
Same key while the first request is still running409 {"error":"idempotency_in_progress"}. Retry shortly.
Key longer than 255 characters400 {"error":"invalid_idempotency_key"}
First attempt ended in 5xx, 429 or 402Not stored. Retry with the same key after fixing the cause (for example, after adding credits).
curl
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:

JSON
{
  "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.

FieldTypeNotes
phonestringRequired. E.164. US numbers in other formats, such as (212) 555-1234, are normalized; anything ambiguous is rejected with 400.
first_name, last_name, namestringOptional.
emailstringOptional.
consentopted_in, opted_out or unknownOptional, default unknown. Only pass opted_in when your system collected explicit consent (a form checkbox, for example). Title cannot grant consent for you.
tagsstring[]Optional. Replaces the contact's tags when the contact already exists.
custom_fieldsobjectOptional. Merged into the existing fields.

Response 201 with the contact record. The status is 201 whether the contact was created or updated.

curl
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
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.

FieldTypeNotes
add_tagsstring[]Tags already on the contact are skipped.
remove_tagsstring[]
add_to_listsstring[]List names, matched case-insensitively. A name that matches no list is ignored.
fieldsobjectAny 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_notestringAdds an internal note to the contact.

Response 200:

JSON
{
  "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
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.

FieldTypeNotes
phonestringRequired. E.164.
intentobjectRequired. A message intent, see Message types below.

Optional header: Idempotency-Key (see Idempotency).

Response 202:

JSON
{
  "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:

StatuserrorWhen
400INVALID_PARAMSphone or intent missing, or the intent fails validation. The message lists every problem, for example Invalid message intent: Button text max 25 chars.
402INSUFFICIENT_CREDITSNot enough credits.
409CONFLICTNo 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.
500INTERNALThe 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
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.

typeFieldsLimits
send_texttext, optional suggestionstext 1 to 2,000 characters
send_rich_cardcard, optional suggestionsOne card
send_carouselcards, optional suggestions2 to 10 cards
send_mediamedia_url or media_key, optional suggestionsOne image or video
send_booking_promptservice_id or service_name, optional text, slots ([{start_time, end_time, label?}]), suggestionstext 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.

typeFieldsWhat it does
replylabel, optional postbackSends the label (or postback) back as the customer's reply.
urllabel, urlOpens a link.
diallabel, phoneStarts a phone call.
locationlabelAsks the customer to share their location.
calendarlabel, event_title, start_time, end_time, optional descriptionAdds 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.

QueryDefaultNotes
recent_days14Window in days. 0 means no window. Negative values are rejected.
limit251 to 100.
flow_idOnly sends made by this flow.

Response 200:

JSON
{
  "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

QueryDefaultNotes
active_onlytruefalse includes drafts and inactive flows.
limit201 to 50.

Response 200, a JSON array ordered by last update, newest first:

JSON
[
  {
    "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.

FieldTypeNotes
flow_idstringRequired.
phonestringE.164. Required unless contact_id is given.
contact_idstringRequired unless phone is given. Must belong to this brand.
first_name, last_name, emailstringUsed only when the contact is created.
consentopted_in, opted_out or unknownUsed only when the contact is created. Default unknown.
tagsstring[]Used only when the contact is created.
initial_contextobjectValues the flow can use as variables, for example { "order_number": "18212" }.

Response 202:

JSON
{
  "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
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.

QueryDefaultNotes
date_from30 days agoISO 8601.
date_tonowISO 8601.
flow_idOnly sends made by this flow.

Response 200:

JSON
{
  "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

FieldTypeNotes
target_urlstringRequired. A public HTTPS URL. Private and loopback addresses are rejected.
event_typesstring[]Required. One or more of the event types below.
descriptionstringOptional.
metadataobjectOptional. Stored with the subscription and returned as is.

Response 201:

JSON
{
  "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
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#

EventFires when
message.receivedA customer replies to the brand by RCS or SMS, or taps a button or chip. Never for STOP, START or HELP.
message.deliveredAn outbound message was delivered.
message.readAn outbound message was read (RCS read receipt).
message.failedAn outbound message failed.
contact.opted_outA contact texted STOP, or the carrier reported an opt-out.
contact.opted_inA contact texted START, or the carrier reported an opt-in.
conversation.human_took_overAgent Connect: a teammate took over a conversation from the agent.
conversation.handed_backAgent Connect: a conversation was handed back to the agent.
connection.testAgent Connect only. Sent to the connection's own endpoint by the "Send test event" button, never to API subscriptions.
flow.startedAccepted when subscribing, but Title does not emit it yet.
flow.completedAccepted when subscribing, but Title does not emit it yet.
contact.createdAccepted when subscribing, but Title does not emit it yet.
contact.updatedAccepted 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.

JSON
{
  "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:

Eventdata fields
message.receivedcontact_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.failedprovider_message_id, contact_id, phone, status, failure_reason (only set on message.failed), occurred_at
contact.opted_out, contact.opted_incontact_id, phone, consent, source (for example keyword), occurred_at
conversation.human_took_over, conversation.handed_backconversation_id, source, user_id, reason, occurred_at, agent_connection_id
webhook.testmessage, 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.

Node.js
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);
}
Python
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:

Node.js
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 responseWhat Title does
2xxDelivery marked succeeded.
4xx other than 408 and 429Delivery marked failed. No retry; the request is considered rejected by you.
5xx, 408, 429, a timeout (10 seconds), or a network errorRetried 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):

JSON
{
  "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:

JSON
{
  "delivery_id": "d4e5...",
  "event_id": "evt_test_...",
  "queued": true,
  "note": "Hit GET /api/v1/webhooks/:id/deliveries to see the result."
}
curl
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

QueryDefaultNotes
statusallOne of pending, succeeded, failed, dead.
limit501 to 200.

Response 200:

JSON
{
  "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.

MethodPathWhat it does
GET/agent/meThe 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/messagesReply in a conversation, or start one by phone number.
POST/agent/typingShow a typing indicator.
POST/agent/handoffHand a conversation to the business's team.
GET/agent/conversations/{id}/messagesRecent 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>.

MethodPathWhat it does
POST/contactsCreate 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/messagesSend one RCS message. Supports Idempotency-Key.
GET/messagesRecent sends with delivery data and cost.
GET/flowsList flows.
GET/flows/{id}One flow, including its structure.
POST/flows/triggerStart a flow for a contact or phone number.
GET/analyticsMessage stats over a date range.
POST/webhooksSubscribe a URL to events.
GET/webhooksList subscriptions.
GET/webhooks/{id}One subscription.
DELETE/webhooks/{id}Unsubscribe.
POST/webhooks/{id}/testSend a test event.
GET/webhooks/{id}/deliveriesDelivery log.
POST/webhooks/{id}/replay/{deliveryId}Re-send a delivery.
/agent/*Agent Connect, see above.