RCS-Box API
Multi-Tenant API Gateway for Google RCS Business Messaging. Send text messages via your brand's agents, list your agents, and receive user-message callbacks — all through a single, authenticated API.
Multi-Tenant
Brand-level isolation with dedicated API keys per tenant.
Tenant allow-list
Each tenant has a configurable country allow-list (default: +49). The send endpoint rejects numbers outside it.
Opt-out Management
Built-in STOP/START blacklist handling per agent.
Base URL
All API requests are made to the following base URL:
https://api.box.rcs.jetzt
All endpoints use JSON for request and response bodies.
Authentication
All /v1/* endpoints require an API key.
Each tenant receives a unique 64-character hex key. You can authenticate using either method:
Option 1: Authorization Header (recommended)
curl -H "Authorization: Bearer $API_KEY" \
https://api.box.rcs.jetzt/v1/agents
Option 2: X-API-Key Header
curl -H "X-API-Key: $API_KEY" \
https://api.box.rcs.jetzt/v1/agents
Important: Keep your API key secret. Do not expose it in client-side code or public repositories. If compromised, contact your administrator to regenerate the key.
Endpoints
Health
/health
No auth required
Public health check endpoint. Use this to verify the API is running.
Response
{
"status": "ok",
"timestamp": "2026-03-26T12:00:00Z"
}
/v1/health
Auth required
Authenticated health check. Verifies both API availability and API key validity.
Response
{
"status": "ok",
"version": "v1",
"timestamp": "2026-03-26T12:00:00Z"
}
Agents
/v1/agents
Auth required
Returns all RCS agents belonging to your tenant's brand. Only agents associated with your brand are visible.
Example Request
curl -H "Authorization: Bearer $API_KEY" \
https://api.box.rcs.jetzt/v1/agents
Response
{
"agents": [
{
"id": 1,
"tenant_id": 1,
"google_agent_id": "brands/acme-corp/agents/my-agent",
"brand_id": "acme-corp",
"name": "ACME Support Bot",
"display_name": "ACME Support Bot",
"description": "ACME customer support agent for order questions.",
"status": "LAUNCHED",
"hosting_region": "EUROPE",
"billing_category": "CONVERSATIONAL",
"use_case": "PROMOTIONAL",
"synced_at": "2026-03-25T10:30:00Z",
"created_at": "2026-01-20T14:00:00Z",
"updated_at": "2026-03-25T10:30:00Z"
}
]
}
Agent Statuses
| Status | Description |
|---|---|
DRAFT | Agent created but not yet submitted for launch |
LAUNCHING | Agent is under review by Google |
LAUNCHED | Agent is live and can send messages |
/v1/agents/:id
Auth required
Returns details for a specific agent. The agent must belong to your tenant.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | The agent's internal ID |
Example Request
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://rcs-box.example.com/v1/agents/1
Response
{
"agent": {
"id": 1,
"tenant_id": 1,
"google_agent_id": "brands/acme-corp/agents/my-agent",
"brand_id": "acme-corp",
"name": "ACME Support Bot",
"display_name": "ACME Support Bot",
"description": "ACME customer support agent for order questions.",
"status": "LAUNCHED",
"hosting_region": "EUROPE",
"billing_category": "CONVERSATIONAL",
"use_case": "PROMOTIONAL",
"synced_at": "2026-03-25T10:30:00Z",
"created_at": "2026-01-20T14:00:00Z",
"updated_at": "2026-03-25T10:30:00Z"
}
}
Error Response (404)
{
"error": "Not Found",
"message": "Agent not found"
}
/v1/agents
Auth required
Create a new RCS agent under your tenant's brand. The agent starts in DRAFT
status and must be launched by an administrator before it can send messages.
Image URIs must be reachable by Google
When you call this endpoint, Google fetches logo_uri and
hero_uri server-side and stores the binary in its own CDN.
The URLs must be publicly reachable HTTPS at request time — localhost,
private networks and unresolved hosts produce a Google URL_ERROR /
INVALID_ARGUMENT and the agent is not created.
End-user devices cache these images aggressively — once a phone has displayed
an agent's logo/hero, updates via
PATCH (updateMask=logoUri)
may not be reflected until the cache expires or the app data is cleared. Upload your
production-quality assets before launching; don't rely on being able to swap a placeholder later.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
display_name | string | Yes | Human-readable name shown to end users (max. 40 chars) |
description | string | Yes | Short description of the agent (max. 100 chars) |
logo_uri | string | Yes | Publicly reachable HTTPS URL — PNG/JPG, 224×224 px, max. 50 KB |
hero_uri | string | Yes | Publicly reachable HTTPS URL — PNG/JPG, 1440×448 px, max. 200 KB |
color | string | Yes | Brand color as hex (e.g. #1DA1F2) |
privacy_uri | string | Yes | HTTPS URL to your privacy policy |
privacy_label | string | No | Display label for the privacy link (default: Datenschutz) |
terms_uri | string | Yes | HTTPS URL to your terms & conditions / Impressum |
terms_label | string | No | Display label for the terms link (default: Impressum) |
website_uri | string | Yes | HTTPS URL to your website — Google requires at least one contact method |
website_label | string | No | Display label for the website link (default: Webseite) |
hosting_region | string | Yes | One of EUROPE, NORTH_AMERICA, ASIA_PACIFIC |
billing_category | string | Yes | One of CONVERSATIONAL, NON_CONVERSATIONAL, BASIC_MESSAGE, SINGLE_MESSAGE, CONVERSATIONAL_MESSAGE, BASIC_MESSAGE_V2 |
use_case | string | No | One of TRANSACTIONAL, PROMOTIONAL, OTP, MULTI_USE (default: PROMOTIONAL) |
Example Request
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "ACME Support Bot",
"description": "ACME customer support agent for order questions.",
"logo_uri": "https://acme.example.com/logo-224.png",
"hero_uri": "https://acme.example.com/hero-1440x448.png",
"color": "#1DA1F2",
"privacy_uri": "https://acme.example.com/privacy",
"terms_uri": "https://acme.example.com/terms",
"website_uri": "https://acme.example.com",
"hosting_region": "EUROPE",
"billing_category": "CONVERSATIONAL",
"use_case": "PROMOTIONAL"
}' \
https://api.box.rcs.jetzt/v1/agents
Smoke testing without real brand assets
For pure end-to-end smoke tests, dummyimage.com returns a real PNG at the exact dimensions and is publicly reachable by Google. Use it only for throwaway test agents — do not ship to production with placeholder images, because end-user phones will cache them.
"logo_uri": "https://dummyimage.com/224x224/1da1f2/ffffff.png",
"hero_uri": "https://dummyimage.com/1440x448/1da1f2/ffffff.png"
Error Response (422 — local validation)
When required fields are missing or invalid, you get a field-keyed hash of stable error codes before any call to Google. Codes are language-neutral so you can map them to your own UX:
{
"error": "Validation failed",
"errors": {
"description": "missing",
"hero_uri": "missing",
"color": "invalid_format"
}
}
Possible codes: missing, too_long, invalid_format, invalid_value.
Error Response (422 — Google rejected payload)
If local validation passes but Google rejects the request (e.g. an image URL it can't fetch, a permission problem, or a constraint it enforces server-side), the upstream message is forwarded:
{
"error": "Google API Error",
"message": "Unexpected response: 400 - The supplied file URL https://example.com/logo.png is invalid, status: URL_ERROR"
}
Response (201)
{
"agent": {
"id": 7,
"tenant_id": 1,
"google_agent_id": "brands/acme-corp/agents/support-bot-xyz",
"brand_id": "acme-corp",
"name": "ACME Support Bot",
"display_name": "ACME Support Bot",
"description": "ACME customer support agent for order questions.",
"status": "DRAFT",
"hosting_region": "EUROPE",
"billing_category": "CONVERSATIONAL",
"use_case": "PROMOTIONAL",
"synced_at": "2026-04-20T09:00:00Z",
"created_at": "2026-04-20T09:00:00Z",
"updated_at": "2026-04-20T09:00:00Z"
}
}
Messages
/v1/agents/:agent_id/messages
Auth required
Send any RCS message type via a specific agent — text, rich card, carousel, file, or text with interactive suggestions. The agent must belong to your tenant.
Pre-launch sends are restricted to accepted testers
If the agent is in
LAUNCHED status, it can send to any recipient (subject to the
tenant's country allow-list). If the agent is still in
DRAFT or any other pre-launch status, the recipient phone
must be registered as a tester on that agent and have accepted the invitation
(tester status = "ACCEPTED"). Otherwise the request returns
422 Agent not launched.
This mirrors Google's RCS tester workflow: register testers via
POST /v1/agents/:id/testers, the user receives an
SMS invite from Google, and once they accept the invite the tester's
status flips to ACCEPTED.
Phone matching is done by normalized digits — spaces and dashes are ignored.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Yes | Recipient phone number in E.164 format. Must match one of the tenant's configured country prefixes (default: +49). |
content_message | object | Yes | The Google RCS contentMessage payload. Must contain exactly one of text, richCard, contentInfo, fileName, or uploadedRbmFile, optionally plus suggestions (max 11). |
The content_message shape mirrors
Google's RCS Business Messaging AgentContentMessage
— see the sub-sections below for each supported type.
Response (201)
{
"message": {
"id": 42,
"tenant_id": 3,
"agent_id": 7,
"google_message_id": "phones/+491701234567/agentMessages/msg-abc-123",
"phone": "+491701234567",
"status": "SENT",
"message_type": "text",
"content": { "text": "Hello!" },
"direction": "outbound",
"sent_at": "2026-04-24T12:00:00Z",
"delivered_at": null,
"error_message": null,
"created_at": "2026-04-24T12:00:00Z",
"updated_at": "2026-04-24T12:00:00Z"
}
}
message_type is one of
text, rich_card,
carousel, file.
Text message
The simplest message type. Max 3072 characters.
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491701234567",
"content_message": {
"text": "Hallo! Willkommen bei ACME."
}
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages
Text with suggestions
Suggestions are interactive chips shown below the message. You can mix any of the four action types per message — up to 11 suggestions total.
Suggestion types
reply— tapping sends a reply back (you receivepostbackDatavia webhook)action.openUrlAction— opens an external URLaction.dialAction— starts a phone callaction.viewLocationAction— opens a location on a map
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491701234567",
"content_message": {
"text": "Wie können wir helfen?",
"suggestions": [
{
"reply": {
"text": "Bestellung verfolgen",
"postbackData": "track_order"
}
},
{
"action": {
"text": "Website öffnen",
"postbackData": "open_site",
"openUrlAction": { "url": "https://acme.example.com" }
}
},
{
"action": {
"text": "Hotline anrufen",
"postbackData": "call_hotline",
"dialAction": { "phoneNumber": "+498000000000" }
}
},
{
"action": {
"text": "Filiale auf Karte",
"postbackData": "show_store",
"viewLocationAction": {
"latLong": { "latitude": 50.9413, "longitude": 6.9583 },
"label": "ACME Köln"
}
}
}
]
}
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages
Standalone Rich Card
A card with title, description, media and up to 4 actions. Use
cardOrientation: VERTICAL for
full-width media at the top, HORIZONTAL for side-by-side.
Media height: SHORT, MEDIUM, or TALL.
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491701234567",
"content_message": {
"richCard": {
"standaloneCard": {
"cardOrientation": "VERTICAL",
"cardContent": {
"title": "Neuer Hoodie 🔥",
"description": "Limited Edition — jetzt im Shop.",
"media": {
"height": "MEDIUM",
"contentInfo": {
"fileUrl": "https://cdn.example.com/hoodie.jpg",
"thumbnailUrl": "https://cdn.example.com/hoodie-thumb.jpg"
}
},
"suggestions": [
{
"action": {
"text": "Jetzt kaufen",
"postbackData": "buy_hoodie",
"openUrlAction": { "url": "https://shop.example.com/hoodie" }
}
},
{
"reply": { "text": "Mehr Infos", "postbackData": "info_hoodie" }
}
]
}
}
}
}
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages
Carousel
Between 2 and 10 cards arranged horizontally. Users swipe through them.
cardWidth: SMALL or MEDIUM.
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491701234567",
"content_message": {
"richCard": {
"carouselCard": {
"cardWidth": "MEDIUM",
"cardContents": [
{
"title": "Produkt A",
"description": "19,99 €",
"media": {
"height": "SHORT",
"contentInfo": { "fileUrl": "https://cdn.example.com/a.jpg" }
},
"suggestions": [
{
"action": {
"text": "Ansehen",
"postbackData": "view_a",
"openUrlAction": { "url": "https://shop.example.com/a" }
}
}
]
},
{
"title": "Produkt B",
"description": "24,99 €",
"media": {
"height": "SHORT",
"contentInfo": { "fileUrl": "https://cdn.example.com/b.jpg" }
},
"suggestions": [
{
"action": {
"text": "Ansehen",
"postbackData": "view_b",
"openUrlAction": { "url": "https://shop.example.com/b" }
}
}
]
}
]
}
}
}
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages
File / Media
Send a file (image, PDF, video, audio) either by URL or by referencing a file previously uploaded to Google's RBM file store.
By URL
Use contentInfo on its own — it is its own member of
Google's contentMessage oneof, not a companion to
fileName. Combining them returns Google
400 INVALID_ARGUMENT (“oneof field 'content' is already set”).
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491701234567",
"content_message": {
"contentInfo": {
"fileUrl": "https://cdn.example.com/rechnungen/12345.pdf",
"thumbnailUrl": "https://cdn.example.com/rechnungen/12345-thumb.jpg"
}
}
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages
Via uploaded RBM file
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491701234567",
"content_message": {
"uploadedRbmFile": {
"fileName": "files/abc123",
"thumbnailName": "files/abc123-thumb"
}
}
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages
Common errors
Invalid content_message (422)
{
"error": "Invalid content_message",
"errors": { "text": "too_long" }
}
Stable error codes per field:
missing— required field absent or emptytoo_long— string exceeds max length (text: 3072)too_many— array exceeds limit (suggestions: 11, card suggestions: 4)invalid_value— value not in the allowed enum (e.g. cardOrientation)invalid_size— carousel needs 2–10 cardsinvalid_structure— wrong shape for the given typemissing_primary— need one oftext,richCard,contentInfo,fileName,uploadedRbmFilemultiple_primary— only one primary key allowed per messageunknown_keys—content_messagecontains unsupported fields
Phone not in tenant allow-list (400)
The Allowed prefixes list reflects your tenant's configured allowed_countries at request time. Example for a tenant configured with Germany and Austria:
{
"error": "Bad Request",
"message": "Phone number is not in the tenant's allow-list. Allowed prefixes: +49, +43"
}
Agent not launched (422)
Returned when the agent is pre-launch and the recipient is not an accepted tester. Register the number as a tester and have them accept the invite before retrying.
{
"error": "Agent not launched",
"message": "Agent '…' is in status DRAFT. Before launch, only accepted testers may receive messages — register +491701234567 as a tester and have them accept the invitation."
}
Note: STOP/START handling is managed internally — when a user replies "STOP" (or
German equivalents) the phone number is added to the agent's blacklist. The original payload is still
forwarded to your webhook_url, but enriched with an rcs_box_action
field (OPT_OUT / OPT_IN) so you can mirror the
state in your own system — see the Opt-out / Opt-in section.
The send endpoint does not currently reject sends to blacklisted numbers; if you need that enforcement
client-side, check your own blacklist before calling this endpoint.
/v1/agents/:agent_id/messages/video
Convenience wrapper: sends a URL inside a plain text message so the RCS client auto-unfurls it
into a native preview. Works for YouTube, Vimeo and direct video links — the client renders a
tappable thumbnail without you having to upload or host the media yourself. An optional
caption is prepended on its own two lines.
Request Body
| Field | Type | Description |
|---|---|---|
phone | string | Recipient phone in E.164 format (e.g. +491791234567). |
video_url | string | Any URL the RCS client will unfurl (YouTube, Vimeo, direct MP4, …). |
caption | string, optional | Text shown above the preview. Blank = URL only. |
Example
curl -X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491791234567",
"video_url": "https://youtu.be/kg9bFxsJXlE",
"caption": "🎥 RCS in 90 Sekunden"
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages/video
Internally this sends a plain {"text": "caption\n\nurl"} contentMessage.
The response shape is identical to Send Message.
/v1/agents/:agent_id/messages/image
Convenience wrapper that sends a standalone image by URL — the RCS client downloads and renders
it inline. Accepts an optional thumbnail_url for a lighter preview
before the full image loads.
Request Body
| Field | Type | Description |
|---|---|---|
phone | string | Recipient phone in E.164 format. |
image_url | string | Publicly reachable HTTPS URL to the full-size image. |
thumbnail_url | string, optional | Lightweight preview loaded first. |
Example
curl -X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491791234567",
"image_url": "https://dummyimage.com/1080x720/1DA1F2/ffffff.png&text=Hallo",
"thumbnail_url": "https://dummyimage.com/270x180/1DA1F2/ffffff.png&text=Hallo"
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages/image
Internally this sends a {"contentInfo": {"fileUrl": "…", "thumbnailUrl": "…"}}
contentMessage. The response shape is identical to Send Message.
/v1/agents/:agent_id/messages/xl
Sends 2–3 content messages in sequence to the same phone. Maps to Telekom's XL Message billing concept: up to three messages delivered within 60 seconds are consolidated into a single billing event, so you can split a richer payload (e.g. text + image + text) across multiple bubbles without paying three times.
Fail-fast with partial success
All messages are validated up-front. If one is invalid, nothing is sent and a
422 is returned with failed_at pointing
at the offending index. If Google rejects a message mid-sequence, the endpoint returns
422 with the successfully-sent logs in messages
plus failed_at and the error on the first failure. Already-sent messages
remain delivered — there is no rollback.
Request Body
| Field | Type | Description |
|---|---|---|
phone | string | Recipient phone in E.164 format. |
messages | array (2–3) | Each item is a full content_message object — text, richCard, contentInfo etc. |
delay_seconds | number, optional | Pause between sends so the handset receives them in order. Defaults to 2.0, clamped to [0, 10]. Google delivers sequential sends async to the handset — without a buffer, messages can arrive out of order. |
Example
curl -X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+491791234567",
"messages": [
{ "text": "🎉 Unsere neue Kollektion ist da!" },
{ "contentInfo": { "fileUrl": "https://dummyimage.com/1080x720" } },
{ "text": "Jetzt im Shop: https://example.com/shop" }
]
}' \
https://api.box.rcs.jetzt/v1/agents/7/messages/xl
Success (201)
{
"messages": [
{ "id": 101, "google_message_id": "…/msg-1", "message_type": "text", … },
{ "id": 102, "google_message_id": "…/msg-2", "message_type": "file", … },
{ "id": 103, "google_message_id": "…/msg-3", "message_type": "text", … }
]
}
Partial success — Google rejects item #1 (422)
{
"messages": [ { "id": 101, …, "message_type": "text" } ],
"failed_at": 1,
"error": "Google API Error",
"message": "…"
}
Testers
While an agent is in DRAFT, Google only
delivers messages to registered tester phone numbers. Use these endpoints to manage the
tester allow-list for each of your agents. Phones must be in the tenant's allowed country prefixes
(V1: +49 only).
/v1/agents/:agent_id/testers
Auth required
Lists testers registered locally for the given agent.
Example Request
curl -H "Authorization: Bearer $API_KEY" \
https://api.box.rcs.jetzt/v1/agents/7/testers
Response (200)
{
"testers": [
{
"id": 12,
"agent_id": 7,
"phone": "+491791234567",
"status": "ACCEPTED",
"google_resource_name": "brands/acme-corp/agents/my-agent/testers/+491791234567",
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-20T10:05:00Z"
}
]
}
/v1/agents/:agent_id/testers
Auth required
Registers a phone number as a tester for the given agent. Forwards to Google Business Communications and stores the result locally. Google triggers an RCS invitation on the device.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Yes | E.164 phone number (e.g. +491791234567). Must match tenant's allowed prefixes. |
Example Request
curl -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"+491791234567"}' \
https://api.box.rcs.jetzt/v1/agents/7/testers
Response (201)
{
"tester": {
"id": 12,
"agent_id": 7,
"phone": "+491791234567",
"status": "INVITED",
"google_resource_name": "brands/acme-corp/agents/my-agent/testers/+491791234567",
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-20T10:00:00Z"
}
}
Error — Phone not in allow-list (400)
Prefixes reflect the tenant's allowed_countries at request time (example shown for DE + AT):
{
"error": "Bad Request",
"message": "Phone number is not in the tenant's allow-list. Allowed prefixes: +49, +43"
}
/v1/agents/:agent_id/testers/:id
Auth required
Removes a tester both at Google and locally. If Google reports the tester as already gone (404), the local record is still removed.
Example Request
curl -X DELETE \
-H "Authorization: Bearer $API_KEY" \
https://api.box.rcs.jetzt/v1/agents/7/testers/12
Response (204)
Empty response body.
/v1/agents/:agent_id/testers/:id/status
Auth required
Pulls the current invite status from Google for the tester and updates the local record. Use this to check whether a user accepted the invitation on their device.
Example Request
curl -H "Authorization: Bearer $API_KEY" \
https://api.box.rcs.jetzt/v1/agents/7/testers/12/status
Response (200)
{
"tester": {
"id": 12,
"agent_id": 7,
"phone": "+491791234567",
"status": "ACCEPTED",
"google_resource_name": "brands/acme-corp/agents/my-agent/testers/+491791234567",
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-20T10:15:00Z"
}
}
Status values: INVITED, PENDING,
ACCEPTED, DECLINED,
UNKNOWN, NOT_FOUND.
Webhooks
Each tenant may configure a webhook_url (set by your administrator).
When Google sends an incoming user message for one of your agents, RCS-Box forwards the payload to that URL.
The shape matches Google's RCS Business Messaging webhook format. Opt-out / opt-in messages are also forwarded,
with an extra rcs_box_action field;
these forwards require an acknowledgement from your endpoint — see the
Opt-out / Opt-in section. Delivery events
(DELIVERED, READ) are not forwarded
today — they only update the internal message status, which the admin UI exposes.
Request format
RCS-Box forwards every callback as an HTTP POST to your configured
webhook_url. The body is the raw JSON
payload — no form-encoding, no wrapper. The connection timeout is 30 seconds; slower
responses are retried via Sidekiq (up to 3× for ack'd flows; see
Opt-out / Opt-in).
| Property | Value |
|---|---|
| Method | POST |
| URL | Your tenant's webhook_url |
| Content-Type | application/json |
| X-RCS-Box-Signature | Hex HMAC-SHA256 over the body, keyed with your API key |
| Body | Raw JSON (the original Google payload, optionally enriched with rcs_box_action) |
| Timeout | 30 s |
| Expected response | 2xx for regular forwards; 200 + {"acknowledged": true} for opt-out/opt-in |
Example forwarded request
POST /your/webhook/path HTTP/1.1
Host: your-domain.example.com
Content-Type: application/json
X-RCS-Box-Signature: 5e7c9a…b3f1
{
"agent": "brands/acme-corp/agents/my-agent",
"senderPhoneNumber": "+491701234567",
"message": { "text": "Hello!" }
}
Signature header
Every forwarded request carries an X-RCS-Box-Signature
header. The value is a lowercase hex-encoded HMAC-SHA256 computed over the request body (the
JSON payload as serialized by RCS-Box), keyed with your API key. Verify it before trusting the payload.
# Ruby
require "openssl"
expected = OpenSSL::HMAC.hexdigest("SHA256", api_key, request.raw_post)
actual = request.headers["X-RCS-Box-Signature"]
reject unless ActiveSupport::SecurityUtils.secure_compare(expected, actual)
Event types
Google sends three callback shapes. You can distinguish them by the top-level keys present in the payload.
Only user messages are forwarded to your webhook_url;
delivery events stay internal.
| Top-level key | Meaning | Forwarded? |
|---|---|---|
| message | User reply containing text, suggestion postback, or media | Yes |
| userMessage | Alternate shape Google uses for the same thing | Yes |
| event | Delivery status (DELIVERED, READ) — updates the internal MessageLog only | No |
Opt-out / Opt-in
When a user replies with one of the keywords below (case-insensitive, exact match after trimming whitespace)
RCS-Box automatically updates the per-agent blacklist and forwards the original payload to your
webhook_url with an extra
rcs_box_action field set to
"OPT_OUT" or "OPT_IN".
| Action | Recognized keywords | Effect |
|---|---|---|
| OPT_OUT | STOP, STOPP, ABMELDEN, ENDE, QUIT, UNSUBSCRIBE, NEIN | Phone added to blacklist immediately (fail-safe), then forwarded for tenant ack |
| OPT_IN | START, ANMELDEN, SUBSCRIBE, JA | Phone stays on blacklist until tenant acknowledges; only then removed |
Example forward (opt-out)
{
"agent": "brands/acme-corp/agents/my-agent",
"senderPhoneNumber": "+491701234567",
"userMessage": { "text": "STOP" },
"rcs_box_action": "OPT_OUT"
}
Matching rules: The keyword check applies only to standalone opt-out / opt-in messages.
Strings that merely contain a keyword (e.g. "please stop spamming") are forwarded as
regular user replies without the rcs_box_action field. Repeated STOPs from the same
number are idempotent.
Tenant acknowledgement (required)
For every forward with rcs_box_action set, your endpoint must respond
200 OK with the JSON body
{"acknowledged": true} within 30 seconds. The acknowledgement is what
proves to RCS-Box (and to your DSGVO audit trail) that you have processed the consent change in your own system.
HTTP/1.1 200 OK
Content-Type: application/json
{ "acknowledged": true }
| Tenant response | OPT_OUT result | OPT_IN result |
|---|---|---|
200 + {"acknowledged": true} |
Audit log marked acked. Phone stays blacklisted (already was). | Audit log marked acked. Phone removed from blacklist. |
| Missing/false ack body, 5xx, or timeout (> 30 s) | Phone stays blacklisted (fail-safe). Job retries up to 3× total (MAX_ATTEMPTS). |
Phone stays blacklisted (compliance fail-safe). Job retries up to 3× total (MAX_ATTEMPTS). |
Why we enforce this: Without your acknowledgement, RCS-Box cannot know that the consent change
actually reached your CRM/email-list/etc. For OPT_IN we therefore fail closed — the user remains
blocked rather than risking unsolicited promotional messages. After all retries are exhausted, the
consent_audit_logs row keeps the last failure as the audit-of-record.
First-time OPT_IN (no prior blacklist entry)
OPT_IN handling is symmetric: even when the user has never been on your blacklist, a START message still
produces a consent_audit_logs row, still gets forwarded with
"rcs_box_action": "OPT_IN", and still requires your
{"acknowledged": true} response. After the ack, the internal
remove from blacklist step is a no-op (there was nothing to remove), but the audit trail records the
explicit consent. You can use the event to register a first-time opt-in in your own system — e.g. flag the
contact as "consent given by RCS reply" in your CRM.
How to test as a tenant
End-to-end you need three things: a public URL that captures incoming HTTP requests, a configured
webhook_url on your tenant, and an
accepted tester number that can send real RCS messages to one of your agents.
1. Set up a capture URL
Open webhook.site — you get a unique URL
like https://webhook.site/<uuid>. Keep the tab open; every request the URL
receives appears live with full body and headers.
2. Configure your tenant webhook_url
Ask your administrator to set the webhook_url
on your tenant to the webhook.site URL from step 1. The setting lives in the admin tenant edit form.
3. Add and accept a tester
Pick an agent, register a tester phone number you control, and accept the invitation on that device. You can do this via POST /v1/agents/:agent_id/testers or in the admin UI.
4. Trigger callbacks
From the tester device, send the following messages to your agent:
| Send from tester | Expected at webhook.site |
|---|---|
| "hello there" | Forward without rcs_box_action |
| "STOP" (or ENDE, NEIN, ABMELDEN, …) | Forward with "rcs_box_action": "OPT_OUT" |
| "START" (or JA, ANMELDEN, …) | Forward with "rcs_box_action": "OPT_IN" |
| "please stop spamming me" | Forward without rcs_box_action — the substring is ignored |
Note about webhook.site & opt-in/opt-out: webhook.site captures requests but does not return
{"acknowledged": true}. STOP/START forwards are therefore retried up to 3
times total before the job gives up, and OPT_IN never removes the phone from the blacklist during this kind of capture-only test.
To exercise the full ack flow, point webhook_url at an endpoint you control that
responds 200 with {"acknowledged": true}.
5. Verify the signature
Each forward carries an X-RCS-Box-Signature
header. Recompute it locally with your API key over the raw request body (see signature header).
If the values match you know the payload is from RCS-Box and untampered.
RCS testing constraint: Google only delivers user messages from accepted tester
numbers while an agent is in DRAFT/PENDING state.
A regular phone you have not registered as a tester will not produce any callback — you will see no
request hit webhook.site. Always test from the tester device.
Error Handling
All errors return a consistent JSON format with an error
and message field.
{
"error": "Unauthorized",
"message": "API key required"
}
HTTP Status Codes
| Code | Meaning | Common Cause |
|---|---|---|
| 200 | OK | Request succeeded |
| 201 | Created | Agent created or message accepted |
| 400 | Bad Request | Missing/invalid parameters, non-German phone number |
| 401 | Unauthorized | Missing or invalid API key |
| 403 | Forbidden | Tenant is inactive or accessing another tenant's resources |
| 404 | Not Found | Agent or resource does not exist |
| 422 | Unprocessable | Agent not launched, Google API rejection |
| 429 | Rate Limited | Too many requests, retry after backoff |
| 500 | Server Error | Internal error, contact support |
Rate Limits
Requests to the Google RCS API are subject to rate limiting. RCS-Box automatically retries rate-limited requests (HTTP 429) with exponential backoff up to 3 times.
Retry strategy: Initial interval of 0.5s with a backoff factor of 2x and 50% randomization. Retries also apply to server errors (500, 502, 503, 504).
Phone Number Format
All phone numbers must be provided in E.164 format. Each tenant has an allow-list of country prefixes;
V1 defaults to Germany only (+49). Formatting characters
(spaces, dashes) are stripped before validation; numbers outside the tenant's allow-list are rejected with HTTP 400.
| Input | Normalized | Accepted? |
|---|---|---|
| +49 170 123 4567 | +491701234567 | Yes |
| +49-170-123-4567 | +491701234567 | Yes |
| +43 677 1234567 | +436771234567 | No (AT) |
| +1 202 555 0100 | +12025550100 | No (US) |