Account
API
Integrate RCSync with your own systems, in both directions: call our API to check consent, and receive every inbound message on a webhook of yours.
Overview
There are two halves to integrating with RCSync, and they're independent — use either, or both.
- You call us. A small server-to-server JSON API under
/api/public/v1/, authenticated with an API key. Today that means Check opt-out status, below. - We call you. Message forwarding POSTs every incoming SMS and RCS message to a URL you host, as it arrives. See Receiving inbound messages at the bottom of this page.
Requests we serve are POST with a JSON body; responses are JSON. The version in the path only changes if a breaking change is ever needed — new fields may be added to a response, or to a webhook payload, at any time, so parse leniently and ignore what you don't recognize.
| Base URL | https://<your-rcsync-host> — the same host you sign in to. |
| Auth | Bearer token in the Authorization header. |
| Content type | application/json on every request with a body. |
| Scope | A key is bound to one brand. There is no brand or account id in any request. |
Getting a key
Keys are created in the app, by someone with a brand user login. If you're the developer and don't have one, ask whoever administers your RCSync account to do this and send you the key over something private.
- Open Integrations in the left-hand nav.
- Click the API Access card.
- Enter a name identifying the system that will use it — “Acme CRM”, “order sync job” — and click Create.
- Copy the key. It looks like
rcsk_followed by 64 hex characters.
Create one key per system. They're revoked individually, so a separate key per integration means turning one off never takes the others down with it. Revoking takes effect immediately — the next request with that key gets a 401.
Authentication
Send the key as a bearer token:
Authorization: Bearer rcsk_a1b2c3…If your stack reserves the Authorization header — some API gateways and proxies do — send it as X-Api-Key instead, with the bare key as the value and no scheme prefix:
X-Api-Key: rcsk_a1b2c3…A missing, malformed, unknown, or revoked key all return the same 401 with no detail about which. That's deliberate — the response can't be used to probe whether a given key ever existed. If you get an unexpected 401, confirm the key hasn't been revoked in Integrations → API Access.
Check opt-out status
POST /api/public/v1/consent/lookup
Given a list of phone numbers, returns whether each has opted out of messages from your brand. Use it to sync suppression state into a CRM, or to check before asking RCSync to send.
Request
| Field | Type | Notes |
|---|---|---|
phoneNumbers | string[] | Required. 1–1,000 entries. Any format — they're normalized to E.164 before lookup, so +1 (555) 123-4567 and 5551234567 resolve alike. |
includeEligibility | boolean | Optional, defaults to false. Adds a full “can I send right now” verdict — see below. |
curl -X POST https://your-rcsync-host/api/public/v1/consent/lookup \
-H "Authorization: Bearer rcsk_a1b2c3…" \
-H "Content-Type: application/json" \
-d '{
"phoneNumbers": ["+15551234567", "5551230000", "nonsense"]
}'Response
{
"requestId": "6f1c0f2e-6b3e-4a1e-9a2f-0f3d5c8e7b21",
"results": [
{
"input": "+15551234567",
"phoneNumber": "+15551234567",
"status": "opted_out",
"optedOutAt": "2026-08-01T14:22:05.000Z",
"optedOutSource": "keyword_inbound",
"optedInAt": "2026-07-04T09:10:00.000Z",
"optedInSource": "open_webhook"
},
{
"input": "5551230000",
"phoneNumber": "+15551230000",
"status": "unknown",
"optedOutAt": null,
"optedOutSource": null,
"optedInAt": null,
"optedInSource": null
},
{
"input": "nonsense",
"phoneNumber": null,
"status": "invalid",
"optedOutAt": null,
"optedOutSource": null,
"optedInAt": null,
"optedInSource": null
}
],
"counts": { "opted_out": 1, "opted_in": 0, "unknown": 1, "invalid": 1 }
}results is positional: one entry per number you sent, in the order you sent them, duplicates included. Zip it onto your own list by index rather than matching on the number. input echoes exactly what you sent; phoneNumber is the normalized E.164 form, or null if it wouldn't parse.
requestId identifies the call in RCSync's logs. Include it if you report a problem.
Status values
| Status | Meaning |
|---|---|
opted_out | They opted out. Don't message them. optedOutAt and optedOutSource say when and how. |
opted_in | A recorded opt-in with no opt-out against it. |
unknown | No consent record at all for this contact. |
invalid | The number couldn't be parsed as a phone number. Nothing was looked up. |
optedOutSource and optedInSource describe how the record was created — for example keyword_inbound (a STOP or START reply), open_webhook (your own system supplied it), or manual. New sources may be added over time, so treat the value as an opaque string rather than a closed set.
Asking whether a send would go through
Consent is one of several reasons a message gets suppressed. Set "includeEligibility": true and every result gains an eligibility object:
"eligibility": { "sendable": false, "reason": "frequency_cap" }| reason | Meaning |
|---|---|
allowed | Nothing is blocking a send to this contact right now. |
opted_out | Suppressed by consent. |
frequency_cap | They've already received the maximum number of messages your brand allows in its rolling window. |
invalid_phone | The number wouldn't parse. |
override_test_number | sendable: true. A test number configured by the platform operator, which bypasses the checks above. |
This runs the same evaluation the send path itself runs, so the two can't disagree about whether a contact is reachable.
Errors
| Status | When | What to do |
|---|---|---|
400 | Malformed JSON, an empty phoneNumbers array, more than 1,000 entries, or a field of the wrong type. | Fix the request. The body carries an error string and, for schema failures, a details object naming the fields. |
401 | Missing, malformed, unknown, or revoked key — or the brand has been deactivated. | Check the header, then check the key still exists in Integrations → API Access. Don't retry automatically. |
413 | The request body exceeded the size limit. | Send fewer numbers per call. |
429 | Too many requests for this key in a short window. | Wait the number of seconds in the Retry-After header, then retry. Batching numbers instead of calling once per contact is usually the real fix. |
Every error response is JSON with an error field holding a human-readable message. A 5xx is safe to retry with backoff; this endpoint only reads data, so a retry can't double-apply anything.
Limits and good behaviour
- Batch. Up to 1,000 numbers per call. One call with 1,000 numbers is far cheaper for both of us than 1,000 calls, and is much less likely to hit the rate limit.
- Sync on a schedule, not per record. Most integrations only need suppression state refreshed periodically — nightly, or before a send — rather than on every row change.
- Store the consent status, re-check eligibility. See the caching note above.
- Rotate keys. Create the replacement, deploy it, then revoke the old one — both work at once, so there's no gap.
Receiving inbound messages
Message forwarding is the other direction: RCSync POSTs a JSON body to a URL you host, once per incoming message, as it arrives. It covers every inbound message on the brand's senders — free-text replies, button taps, shared files and locations, and STOP/START/HELP keywords.
Forwarding is a copy. It never changes how a message is handled inside RCSync, and consent keywords still take effect exactly as they would otherwise — a forwarded STOP has already been applied by the time you receive it.
How we authenticate to you
Your endpoint is on the public internet, so it needs to reject anything that isn't us. Whoever configures the integration picks one of these; you implement the matching check.
| Method | What we send |
|---|---|
| No authentication | Nothing. Only defensible when the URL itself is long and unguessable. |
| Bearer token | Authorization: Bearer <token> |
| Basic authentication | Authorization: Basic <base64 user:password> |
| Custom header | A header name of your choosing, e.g. X-Api-Key: <value> |
| Signed request (HMAC) | X-RCSync-Signature: sha256=<hex> — see below. Nothing secret crosses the wire, which makes this the strongest of the five. |
Every request, whichever method is configured, also carries:
| Header | Value |
|---|---|
X-RCSync-Event | inbound_message for a real message, test for the test button. |
X-RCSync-Timestamp | Unix milliseconds at the moment we sent it. |
Content-Type | application/json |
Verifying the signature
With HMAC configured, we sign <timestamp>.<body> — the timestamp header, a literal dot, then the raw request body — using HMAC-SHA256 with the shared secret, hex-encoded, prefixed with sha256=.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody MUST be the exact bytes we sent. Parsing the JSON and
// re-serializing it changes whitespace and key order, and the
// signature will never match. In Express, use express.raw().
function isFromRCSync(rawBody, headers, secret) {
const timestamp = headers["x-rcsync-timestamp"];
const signature = headers["x-rcsync-signature"];
if (!timestamp || !signature) return false;
// Reject anything outside a tolerance window, so a request captured
// off the wire can't be replayed at you later.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
const expected =
"sha256=" +
createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
// Constant-time compare: a plain === leaks how much of the
// signature was correct via response timing.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}The payload
{
"type": "inbound_message",
"version": 1,
"id": "provider-message-id",
"brandId": "…",
"channel": "sms",
"from": "+15551234567",
"to": "+15559876543",
"sender": { "id": "…", "name": "Acme Coffee" },
"message": {
"type": "text_reply",
"text": "yes please",
"classification": "other",
"keyword": null
},
"campaignId": "…",
"pairedMessageId": null,
"timestamp": "2026-08-28T16:00:00.000Z"
}| Field | Notes |
|---|---|
id | The provider's message id. Stable across retries — this is your dedupe key. |
channel | sms or rcs. |
from | The contact's number in full E.164. Not masked — a reply isn't actionable in your system without it. |
to | The address they messaged: the sender's number, or its RCS agent reference. |
message.type | text_reply, chip_tapped, file_reply, location_reply, or opted_out. |
message.classification | What we understood it to be: other (a genuine reply), opt_in, opt_out, or help. When it isn't other, message.keyword names the keyword that matched. |
campaignId | The campaign we attributed the reply to, or null when we couldn't. A message forwards either way. |
pairedMessageId | The message being replied to, when the provider threaded the reply. Often null on SMS. |
timestamp | When the message reached us, ISO 8601. |
Delivery contract
- One attempt, no retry. We post once with a short timeout. A slow or broken endpoint can never delay or block a reply being handled inside RCSync — which also means a message you drop is not resent.
- At-least-once, not exactly-once. A provider retry upstream can deliver the same message to you twice. Dedupe on
id. - Respond
2xxquickly. Acknowledge first and do your work asynchronously; anything slow risks the timeout, and the result is a dropped message rather than a retried one. - Order isn't guaranteed. Messages are posted as they arrive and in parallel. Use
timestampif sequence matters to you.
