Rich messages are the reason to use RCS at all: an image, a card, a swipeable
row of cards, and buttons that dial a number, open a link or drop an event in
the calendar.
They all go through the same endpoint as plain text —
POST /ng/v1/rcs/messages — and adding any of them switches the message to the
rich path automatically.
Rich messages need an RBM agent
A rich send is addressed from an agent , not just from a name. If your sender
has no agent mapped you get a 400 before anything is billed:
sender "FirstDigits" has no RBM agent registered; rich messages need one
Registering the sender name and mapping its agent are two different steps — see
Sender IDs & agents .
suggestions work alongside plain content. No card to design, no layout to
get right — just make the notification actionable.
"senderId" : " FirstDigits " ,
"destination" : " 2348020000000 " ,
"content" : " Your order #AB123 is out for delivery. " ,
"displayText" : " Track it " ,
"postback" : { "data" : " track:AB123 " }
"displayText" : " Call support " ,
"postback" : { "data" : " call_support " },
"dialPhoneNumber" : { "phoneNumber" : " +2348000000000 " }
Each chip is either a reply or an action, never both.
displayText and postback.data are independent — on purpose
The user sees displayText. Your agent receives postback.data.
That means the label can change — translation, a rewrite, A/B copy — without
breaking the code that handles the tap. Put a stable identifier in data, not
the label:
{ "displayText" : " Track it " , "postback" : { "data" : " track:AB123 " } }
An action chip does something on the handset as well as posting back. Set
exactly one:
Field What it does urlAction.openUrl.urlOpens a link. dialerAction.dialPhoneNumber.phoneNumberStarts a call. International format, with the +. calendarAction.createCalendarEventOffers to create an event — startTime, endTime, title, optional description. Times are RFC 3339. mapAction.showLocationPins a place — location.latitude, location.longitude, optional label and fallbackUrl. mapAction.requestLocationPushAsks the user to share their location. Pass {}.
"displayText" : " Add to calendar " ,
"postback" : { "data" : " cal:appt-9931 " },
"startTime" : " 2026-09-14T09:00:00Z " ,
"endTime" : " 2026-09-14T09:30:00Z " ,
"title" : " Dental check-up " ,
"description" : " Bring your card. "
richCard is media plus a title, a description, and its own chips.
curl -X POST https://api.9bits.net/ng/v1/rcs/messages \
-H " Authorization: Bearer $NINEBITS_RCS_KEY " \
-H " Content-Type: application/json " \
"senderId": "FirstDigits",
"destination": "2348020000000",
"content": "Your order has shipped",
"layout": { "cardOrientation": "VERTICAL" },
"title": "Order AB123 shipped",
"description": "Arriving Thursday between 9am and 12pm.",
"mediaUrl": "https://cdn.example.com/parcel.jpg",
"mediaContentType": "image/jpeg",
"height": "MEDIUM_HEIGHT",
"contentDescription": "A parcel on a doorstep"
"displayText": "Track parcel",
"postback": { "data": "track:AB123" },
"urlAction": { "openUrl": { "url": "https://example.com/track/AB123" } }
await fetch ( ' https://api.9bits.net/ng/v1/rcs/messages ' , {
Authorization: ` Bearer ${ process . env . NINEBITS_RCS_KEY } ` ,
' Content-Type ' : ' application/json ' ,
destination: ' 2348020000000 ' ,
content: ' Your order has shipped ' ,
layout: { cardOrientation: ' VERTICAL ' },
title: ' Order AB123 shipped ' ,
description: ' Arriving Thursday between 9am and 12pm. ' ,
mediaUrl: ' https://cdn.example.com/parcel.jpg ' ,
mediaContentType: ' image/jpeg ' ,
mediaFileSize: 148213 , // bytes — required, and not derived for you
contentDescription: ' A parcel on a doorstep ' ,
displayText: ' Track parcel ' ,
postback: { data: ' track:AB123 ' },
urlAction: { openUrl: { url: ' https://example.com/track/AB123 ' } },
" https://api.9bits.net/ng/v1/rcs/messages " ,
headers = { " Authorization " : f "Bearer {os.environ [ ' NINEBITS_RCS_KEY ' ] } " } ,
" senderId " : " FirstDigits " ,
" destination " : " 2348020000000 " ,
" content " : " Your order has shipped " ,
" layout " : { " cardOrientation " : " VERTICAL " },
" title " : " Order AB123 shipped " ,
" description " : " Arriving Thursday between 9am and 12pm. " ,
" mediaUrl " : " https://cdn.example.com/parcel.jpg " ,
" mediaContentType " : " image/jpeg " ,
" height " : " MEDIUM_HEIGHT " ,
" contentDescription " : " A parcel on a doorstep " ,
" displayText " : " Track parcel " ,
" postback " : { " data " : " track:AB123 " },
" openUrl " : { " url " : " https://example.com/track/AB123 " }
Field Values Notes layout.cardOrientationVERTICAL, HORIZONTALVertical puts the media above the text. layout.imageAlignmentLEFT, RIGHTHORIZONTAL cards only.
Two or more cards the user swipes through. Same content shape as a single
card, in a list.
"senderId" : " FirstDigits " ,
"destination" : " 2348020000000 " ,
"layout" : { "cardOrientation" : " VERTICAL " , "cardWidth" : " MEDIUM_WIDTH " },
"title" : " Lagos → Abuja " ,
"description" : " From ₦48,000 return " ,
"mediaUrl" : " https://cdn.example.com/abuja.jpg " ,
"mediaContentType" : " image/jpeg " ,
"height" : " MEDIUM_HEIGHT "
"title" : " Lagos → Accra " ,
"description" : " From ₦92,000 return " ,
"mediaUrl" : " https://cdn.example.com/accra.jpg " ,
"mediaContentType" : " image/jpeg " ,
"height" : " MEDIUM_HEIGHT "
A carousel needs at least two cards
One card is not a carousel, and it is rejected:
a carousel needs at least two cards; send a single card as richCard
This is caught here rather than upstream, where it would come back as a generic
validation failure after the message was billed.
Field Values Notes layout.cardOrientationVERTICALVertical in practice. layout.cardWidthSMALL_WIDTH, MEDIUM_WIDTHThe knob that actually matters.
"mediaUrl" : " https://cdn.example.com/parcel.jpg " ,
"mediaContentType" : " image/jpeg " ,
"height" : " MEDIUM_HEIGHT " ,
"contentDescription" : " A parcel on a doorstep "
Four things worth knowing:
mediaUrl must be publicly reachable. The platform fetches it — a URL
behind your VPN, your auth, or localhost produces a card with a hole in it.
mediaFileSize is the size in bytes and it is required. It is not derived
for you: fetching the URL just to measure it would put a network round trip
into every send, for a number you already have.
height is SHORT_HEIGHT, MEDIUM_HEIGHT or TALL_HEIGHT. TALL_HEIGHT
is rejected on a carousel .
contentDescription is alt text , read out by screen readers. Set it.
Optional thumbnailUrl, thumbnailContentType and thumbnailFileSize follow
the same rules.
A handset without RCS cannot render any of this. Rich sends do not silently
downgrade to SMS — watch for Undelivered in the
delivery report and send the
SMS version yourself when it arrives.
Writing the plain-text equivalent first, then enriching it, tends to be the
easier order: you end up with a fallback you have already tested.