Cadence

Docs

Onboard in minutes. Get a sandbox key, open an agent-initiated conversation over RCS, and let SMS fallback and compliance be handled for you.

FOR AI AGENTS

Reading this as an agent?

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

1. Get a sandbox key (instant)

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.

2. Open a conversation

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) });
});

Let the server run the conversation

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.

Receiving replies

Decide this before anything else — and mind that two different things here are called a webhook.

  1. Attach a flow (above). Cadence answers each reply itself and records the answers; nothing of yours has to be running. Best when the conversation is scripted.
  2. Register a delivery webhook — 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.
  3. 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.
  4. Poll 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.

Delivery webhooks

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.

Will your brand pass carrier review?

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.

3. Go live

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.

Machine-readable spec

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.

API reference

EndpointWhat it does
POST /v1/signupInstant sandbox API key
POST /v1/conversationsOpen an agent-initiated conversation (opt-in required)
POST /v1/conversations/{id}/messagesSend the next turn (auto-degrades to SMS text)
GET /v1/conversations/{id}State, channel, trust level, turn count
GET /v1/conversations/{id}/messagesMessage history with per-message channel (shows the RCS→SMS fallback)
POST /v1/conversations/{id}/closeClose a conversation
GET /v1/conversations/{id}/answersStructured answers from a server-driven flow — per step, with the chip that was tapped
POST /v1/conversations/{id}/simulate-inboundDeliver a reply as though the recipient sent it (sandbox keys only) — how you exercise a flow end to end
POST /v1/webhooksRegister 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 liveWhat it does
GET /v1/onboardingStart here. A checklist computed from your real state, each step naming the call that completes it
POST /v1/brandsCreate the brand carriers will review (snake_case fields, in and out)
POST /v1/brands/{id}/checkReadiness check — policies, domain, artwork. Generates the copy you are missing
POST /v1/brands/{id}/agentsCreate an agent. Hosting region and use case are permanent once synced
POST /v1/brands/{id}/sync-googleThe first irreversible step. Send {"dryRun": true} first — it creates nothing and shows exactly what would be sent
PATCH /v1/agents/{id}/launch-infoThe launch questionnaire, which cannot be edited after submission
GET /v1/agents/{id}/launch-readinessWhat 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).

Field notes

MCP server

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