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

GET /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"
}
GET /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

GET /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
DRAFTAgent created but not yet submitted for launch
LAUNCHINGAgent is under review by Google
LAUNCHEDAgent is live and can send messages
GET /v1/agents/:id Auth required

Returns details for a specific agent. The agent must belong to your tenant.

Path Parameters

Parameter Type Description
idintegerThe 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"
}
POST /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_namestringYesHuman-readable name shown to end users (max. 40 chars)
descriptionstringYesShort description of the agent (max. 100 chars)
logo_uristringYesPublicly reachable HTTPS URL — PNG/JPG, 224×224 px, max. 50 KB
hero_uristringYesPublicly reachable HTTPS URL — PNG/JPG, 1440×448 px, max. 200 KB
colorstringYesBrand color as hex (e.g. #1DA1F2)
privacy_uristringYesHTTPS URL to your privacy policy
privacy_labelstringNoDisplay label for the privacy link (default: Datenschutz)
terms_uristringYesHTTPS URL to your terms & conditions / Impressum
terms_labelstringNoDisplay label for the terms link (default: Impressum)
website_uristringYesHTTPS URL to your website — Google requires at least one contact method
website_labelstringNoDisplay label for the website link (default: Webseite)
hosting_regionstringYesOne of EUROPE, NORTH_AMERICA, ASIA_PACIFIC
billing_categorystringYesOne of CONVERSATIONAL, NON_CONVERSATIONAL, BASIC_MESSAGE, SINGLE_MESSAGE, CONVERSATIONAL_MESSAGE, BASIC_MESSAGE_V2
use_casestringNoOne 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

POST /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
phonestringYesRecipient phone number in E.164 format. Must match one of the tenant's configured country prefixes (default: +49).
content_messageobjectYesThe 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 receive postbackData via webhook)
  • action.openUrlAction — opens an external URL
  • action.dialAction — starts a phone call
  • action.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

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 empty
  • too_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 cards
  • invalid_structure — wrong shape for the given type
  • missing_primary — need one of text, richCard, contentInfo, fileName, uploadedRbmFile
  • multiple_primary — only one primary key allowed per message
  • unknown_keyscontent_message contains 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.

POST /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
phonestringRecipient phone in E.164 format (e.g. +491791234567).
video_urlstringAny URL the RCS client will unfurl (YouTube, Vimeo, direct MP4, …).
captionstring, optionalText 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.

POST /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
phonestringRecipient phone in E.164 format.
image_urlstringPublicly reachable HTTPS URL to the full-size image.
thumbnail_urlstring, optionalLightweight 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.

POST /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
phonestringRecipient phone in E.164 format.
messagesarray (2–3)Each item is a full content_message object — text, richCard, contentInfo etc.
delay_secondsnumber, optionalPause 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).

GET /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"
    }
  ]
}
POST /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
phonestringYesE.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"
}
DELETE /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.

GET /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
MethodPOST
URLYour tenant's webhook_url
Content-Typeapplication/json
X-RCS-Box-SignatureHex HMAC-SHA256 over the body, keyed with your API key
BodyRaw JSON (the original Google payload, optionally enriched with rcs_box_action)
Timeout30 s
Expected response2xx 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?
messageUser reply containing text, suggestion postback, or mediaYes
userMessageAlternate shape Google uses for the same thingYes
eventDelivery status (DELIVERED, READ) — updates the internal MessageLog onlyNo

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
200OKRequest succeeded
201CreatedAgent created or message accepted
400Bad RequestMissing/invalid parameters, non-German phone number
401UnauthorizedMissing or invalid API key
403ForbiddenTenant is inactive or accessing another tenant's resources
404Not FoundAgent or resource does not exist
422UnprocessableAgent not launched, Google API rejection
429Rate LimitedToo many requests, retry after backoff
500Server ErrorInternal 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+491701234567Yes
+49-170-123-4567+491701234567Yes
+43 677 1234567+436771234567No (AT)
+1 202 555 0100+12025550100No (US)