Onboard in minutes. Get a sandbox key, open an agent-initiated conversation over RCS, and let SMS fallback and compliance be handled for you.
Follow cadencercs.com/llms.txt — a machine-readable spec of the API and the self-onboarding flow. You can get a sandbox key and send a conversation without a human in the loop. (TypeScript SDK: npm install @cadencercs/sdk. MCP server: npx @cadencercs/mcp — point your assistant at it with a CADENCE_API_KEY and it can open conversations, handle replies and walk a brand through carrier onboarding as tools.)
Send your email and accept the terms. Without an email you get an unlinked key that can only simulate sends and cannot be upgraded — you'd have to start again.
curl -X POST https://api.cadencercs.com/v1/signup \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","acceptTerms":true}'
Authenticate every request with Authorization: Bearer <api_key>. A sk_sandbox_ key simulates RCS delivery and SMS fallback end to end — nothing reaches a real phone, and you can drive replies yourself with POST /v1/conversations/{id}/simulate-inbound.
Browser note: CORS is pinned to https://cadencercs.com, so call the API from your server, not from a page.
Your agent speaks first (RCS is agent-initiated), and you pass the opt-in provenance you collected.
The SDK is on npm: npm install @cadencercs/sdk — zero dependencies, ESM, runs on Node 18+, Deno, Bun, Workers and browsers. The REST API is equally supported and equally documented in llms.txt; the SDK is a convenience, not the sanctioned path.
import { Messaging } from "@cadencercs/sdk";
const { apiKey } = await Messaging.signup(); // or reuse your key
const m = new Messaging({ apiKey });
const convo = await m.open({
to: "+15551234567", agentId: "reminders", brandId: "clinic_42",
useCase: "transactional",
optin: { method: "appointment_booking", capturedAt },
opening: {
text: "Your 3pm is confirmed. Reschedule?",
suggestions: ["Reschedule", "Cancel appt", "All good"],
smsFallbackText: "Your 3pm is confirmed. Reply 1 reschedule, 2 cancel, 3 all good.", // required with rich content
},
});
convo.onFellback(() => /* RCS → SMS: re-render rich UI as text */);
convo.onReply(async (intent) => {
await convo.send({ text: await agent.respond(intent) });
});
Pass a flow when you open a conversation and Cadence answers every reply itself. Your process doesn't have to be running — someone who replies an hour later, or after your deploy, still gets the next step. See Receiving replies for how this compares to the alternatives.
Note there is no opening: the flow's first step is the opening, and the server sends it. Passing a different opening is refused with OPENING_IS_STEP_ONE — otherwise the recipient is asked nothing and their first reply gets filed against a step they never saw.
await m.open({
to: "+12025550123", agentId, brandId, useCase: "conversational",
optin: { method: "web_form", capturedAt: new Date().toISOString() },
flow: {
id: "checkin", name: "Weekly check-in", closing: "Thanks!",
disclaimer: "Msg & data rates may apply. Reply STOP to opt out.",
steps: [
{ id: "mood", text: "How was your week?",
suggestions: ["Good", "Mixed", "Rough"],
smsFallbackText: "How was your week? Reply 1 Good, 2 Mixed, 3 Rough." },
{ id: "more", text: "Anything you'd like to add?" },
],
},
});
The response returns serverDriven: true. A flow that can't be run is refused with 422 FLOW_INVALID and the specific reasons, before anyone is stranded halfway through it.
STOP, HELP and START are answered for you on both the RCS and SMS legs — carriers require all three, so it can't depend on your process being subscribed. You still get the events; don't answer them again or the recipient gets two replies.
Check whether a number is reachable before you send with GET /v1/agents/{id}/reachability?to=+1…. A false isn't an error — it means the send falls back to SMS.
Decide this before anything else — and mind that two different things here are called a webhook.
flow (above). Cadence answers each reply itself and records the answers; nothing of yours has to be running. Best when the conversation is scripted.POST /v1/webhooks. We POST each event to your endpoint, signed. Nothing of yours runs between events either, so this is the one for a Cloud Function, a Lambda, or anything that wakes on a request. Use it when your own model decides what to say next.GET /v1/events — an SSE stream. Same events, but it needs a held-open connection: a reply arriving while your process is down is recorded and never answered.GET /v1/conversations/{id}/answers or /messages. Fine for batch work.The first two compose: attach a flow and subscribe, and you get the scripted conversation plus a push when each reply lands and when it completes.
POST /v1/webhooks is the one you want. PUT /v1/agents/{id}/webhook is not. Same word, opposite direction: that one tells Google to push an agent’s traffic to a URL of yours instead of to Cadence, which means Cadence stops seeing that agent’s replies and stops driving its flows. It exists for customers who want the raw RBM feed and will verify Google’s signatures themselves.
curl -X POST https://api.cadencercs.com/v1/webhooks \
-H "authorization: Bearer $CADENCE_KEY" -H 'content-type: application/json' \
-d '{"url":"https://basal.ai/api/cadence","events":["reply","completed","optout"]}'
The response carries secret once — it’s derived from a server secret rather than stored, so it can’t be read back. Every request we send is signed:
Cadence-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
Cadence-Event: reply
Cadence-Event-Id: evt_… # stable across retries — dedupe on this
Cadence-Attempt: 1
Recompute HMAC-SHA256 over the exact string ${t}.${rawBody}, compare in constant time, and reject a t outside your tolerance (ours is 300s). Sign the raw body bytes. Re-serialising the parsed JSON often produces byte-identical output today — which is the trap, not the reassurance. It passes every test you write and fails silently later, when a proxy reformats the body or a number normalises (1.0 becomes 1). The signature is over bytes, not over JSON equivalence.
{ "id": "evt_…", "type": "reply", "createdAt": "…",
"data": { "conversationId": "…", "agentId": "…", "brandId": "…", "to": "+1…",
"intent": "free_text", "body": "182.4",
"optionIndex": 1, "optionLabel": "About right" } }
A completed event carries the answers inline, so a serverless handler woken by it doesn’t have to call back for the thing the event is about. data.answers is the array; the flow’s own metadata sits alongside it at data.flow.
intent is one of free_text, postback, optin, optout, help. A reply event carries the first two in practice — opt-out and HELP arrive as their own event types.
Inbound data.body is what the recipient sent, and it differs by channel for the same intent: a chip tap on RCS arrives as the label, while the equivalent SMS reply arrives as the digit they typed. Branch on optionLabel, never on body — optionIndex and optionLabel are resolved server-side against the chips actually offered and are identical on both legs. (deliveredText is a field on outbound messages in GET /v1/conversations/{id}/messages; webhook payloads do not carry it.)
Delivery is at-least-once — dedupe on Cadence-Event-Id. Anything but a 2xx is a failure, as is taking longer than 10s; failures retry six times over about nine hours. Twenty consecutive events that exhaust their retries disable the endpoint, with the reason on GET /v1/webhooks/{id}.
POST /v1/webhooks/{id}/test sends a signed ping now and reports exactly what your endpoint did — use it before real traffic. GET /v1/webhooks/{id}/deliveries lists recent attempts with your endpoint’s own response.
Before you submit for RCS or 10DLC approval, run the free RCS Readiness Check — it validates your domain age, privacy policy, messaging terms, opt-in page, and brand/entity consistency, and generates the compliant copy you're missing. A clean submission clears in ~4–5 business days; one rejection costs 2–4 weeks.
When you're ready to send to real phones, onboard your brand (we handle the 10DLC / RCS carrier compliance) and swap your sk_sandbox_ key for a sk_live_ key. Same code.
Rehearse the irreversible step first. POST /v1/brands/{id}/sync-google creates the agent at Google, and Google has deprecated agent deletion — so it can never be removed, and its hosting region and use case are fixed forever at creation. Send {"dryRun": true} and you get the exact payload each agent would be created with, the fields that become permanent, and anything that would fail. It creates nothing, needs no confirmation, and works on a sandbox key. Then re-send with {"confirm": "irreversible"}.
Do the same for artwork before that call: POST /v1/brands/{id}/check inspects your logo and hero and marks anything permanent: true. Bad artwork is usually a non-blocking warning — Google accepts it — and it still freezes forever when verification is requested, so blocking alone is the wrong thing to gate on.
cadencercs.com/openapi.json — OpenAPI 3.1 with full request and response schemas, examples, and every irreversible operation marked x-irreversible. Paths are generated from the live route table and the request schemas from the validators that actually run, so it cannot describe a route that doesn’t exist or a body the server would reject. llms.txt is the same contract in prose.
Shipping an RCS agent for the first time? Nine undocumented traps in Google’s RBM API — a required field marked optional, a profile that freezes on request, palette PNGs silently destroyed. Each one costs days and three cannot be undone.
Planning a launch date? Your RCS agent is approved and still reaches nobody — all three major US carriers run their own approval and require a commercial agreement Google does not broker. What the region list actually returns, the verification deadlock, and what reaches a handset today.
| Endpoint | What it does |
|---|---|
POST /v1/signup | Instant sandbox API key |
POST /v1/conversations | Open an agent-initiated conversation (opt-in required) |
POST /v1/conversations/{id}/messages | Send the next turn (auto-degrades to SMS text) |
GET /v1/conversations/{id} | State, channel, trust level, turn count |
GET /v1/conversations/{id}/messages | Message history with per-message channel (shows the RCS→SMS fallback) |
POST /v1/conversations/{id}/close | Close a conversation |
GET /v1/conversations/{id}/answers | Structured answers from a server-driven flow — per step, with the chip that was tapped |
POST /v1/conversations/{id}/simulate-inbound | Deliver a reply as though the recipient sent it (sandbox keys only) — how you exercise a flow end to end |
POST /v1/webhooks | Register a signed delivery webhook — we POST inbound activity to your endpoint |
GET /v1/events?agentId=… | SSE stream of replies + RCS→SMS flips |
That is the messaging half. There is another one. Everything needed to go live is
also over the API — creating a brand, the readiness check, artwork, agents, the launch
questionnaire, keys, usage. A sandbox key takes arbitrary agentId and
brandId strings, so it is entirely possible to build a working integration without
ever meeting any of it, and then meet all of it on the day you want to send to a real phone.
| Going live | What it does |
|---|---|
GET /v1/onboarding | Start here. A checklist computed from your real state, each step naming the call that completes it |
POST /v1/brands | Create the brand carriers will review (snake_case fields, in and out) |
POST /v1/brands/{id}/check | Readiness check — policies, domain, artwork. Generates the copy you are missing |
POST /v1/brands/{id}/agents | Create an agent. Hosting region and use case are permanent once synced |
POST /v1/brands/{id}/sync-google | The first irreversible step. Send {"dryRun": true} first — it creates nothing and shows exactly what would be sent |
PATCH /v1/agents/{id}/launch-info | The launch questionnaire, which cannot be edited after submission |
GET /v1/agents/{id}/launch-readiness | What is still blocking, before anything is permanent |
These two tables are a summary, not the API. All 60 operations with full request and response schemas are in openapi.json, and llms.txt covers behaviour and sequence in prose.
Constraints: agent-initiated only · opt-in required per recipient · rich content must include smsFallbackText · chip labels can't collide with carrier keywords (STOP/HELP/CANCEL).
smsFallbackText is required whenever opening/message includes richCard or suggestions — the send is rejected with MISSING_SMS_FALLBACK otherwise. It's what the recipient sees if the message is delivered over SMS.optin: method is a free-form string describing how consent was captured (e.g. appointment_booking, web_form, verbal) — no fixed enum. capturedAt is an ISO 8601 timestamp. evidenceRef is an optional free-form reference to your consent record.STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, REVOKE, START, UNSTOP, YES, HELP, INFO) — every word we intercept as opt-out, opt-in or help. So "Cancel appt" and "Yes please" are fine; "Cancel" and "Yes" are not, because a chip carrying one is read as that instruction rather than as an answer.turnCount = number of messages exchanged (inbound + outbound); it counts toward turnCeiling (default 30). The opening counts as turn 1.channel is sms and channelConfidence is committed from the open response (no within-conversation flip to observe). (b) A recipient who was RCS-reachable but a message goes undelivered flips mid-conversation — you'll see an rcs message followed by an sms one in the message history, and a fellback event on the SSE stream GET /v1/events. The message-send response itself returns only a providerMsgId.channelConfidence: provisional = the channel is a best-guess that may still flip (e.g. an RCS send that could fall back to SMS); committed = the channel is final (e.g. delivered over SMS).trustLevel: verified_rcs (RCS with a verified, branded sender), branded_sms (SMS from a pre-disclosed/branded sender), or unbranded_sms (plain SMS, no branding).agentId, turnCount, channelConfidence).Not published yet. The MCP server exists in the repository but is not distributed, so there is no URL or package to connect to today. Tools it exposes: get_started, open_conversation, send, get_conversation, close_conversation. Until it ships, use the REST API — it is the same surface.
Cadence is a service of Salus, Inc. · Platform Terms · Privacy · Consumer messaging terms