Message Best Practices
This guide shows you how to harden your send path for production: store per-recipient message IDs, make critical sends idempotent, apply RCS-specific send rules, and estimate spend before a bulk send. It assumes you already send messages through an SDK or the REST API. Adjacent production concerns each have a dedicated guide: Error Handling, Message Status Tracking, and Testing & Debugging. The sections below link to them where they fit into the send path.
Store Message IDs
Every send returns one recipient entry per (recipient, channel) pair. Store each message_id, because webhook status events and GET /v3/messages/{id} lookups key on it:
// Store in your database - one entry per (recipient, channel) pair
const recipients = response.data.recipients;
for (const r of recipients) {
await db.messages.create({
sentMessageId: r.message_id,
recipient: r.to,
channel: r.channel,
templateId: response.data.template_id,
templateName: response.data.template_name,
status: response.data.status
});
}# Store in your database - one entry per (recipient, channel) pair
for r in response.data.recipients:
db.messages.create({
"sent_message_id": r.message_id,
"recipient": r.to,
"channel": r.channel,
"template_id": response.data.template_id,
"template_name": response.data.template_name,
"status": response.data.status
})// Store in your database - one entry per (recipient, channel) pair
for _, r := range response.Data.Recipients {
db.Messages.Create(MessageRecord{
SentMessageID: r.MessageID,
Recipient: r.To,
Channel: r.Channel,
TemplateID: response.Data.TemplateID,
TemplateName: response.Data.TemplateName,
Status: response.Data.Status,
})
}// Store in your database - one entry per (recipient, channel) pair
for (var r : response.data().recipients()) {
db.messages().create(MessageRecord.builder()
.sentMessageId(r.messageId())
.recipient(r.to())
.channel(r.channel())
.templateId(response.data().templateId())
.templateName(response.data().templateName())
.status(response.data().status())
.build());
}// Store in your database - one entry per (recipient, channel) pair
foreach (var r in response.Data.Recipients)
{
await db.Messages.CreateAsync(new MessageRecord
{
SentMessageId = r.MessageId,
Recipient = r.To,
Channel = r.Channel,
TemplateId = response.Data.TemplateId,
TemplateName = response.Data.TemplateName,
Status = response.Data.Status
});
}// Store in your database - one entry per (recipient, channel) pair
foreach ($result->data->recipients as $r) {
$db->messages->create([
'sent_message_id' => $r->message_id,
'recipient' => $r->to,
'channel' => $r->channel,
'template_id' => $result->data->template_id,
'template_name' => $result->data->template_name,
'status' => $result->data->status
]);
}# Store in your database - one entry per (recipient, channel) pair
result.data.recipients.each do |r|
db.messages.create(
sent_message_id: r.message_id,
recipient: r.to,
channel: r.channel,
template_id: result.data.template_id,
template_name: result.data.template_name,
status: result.data.status
)
endMake Critical Sends Idempotent
For critical messages (OTP, payments), use idempotency keys via the Idempotency-Key header so a retry after a network failure can't double-send:
curl -X POST "https://api.sent.dm/v3/messages" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp_user_123_20250115" \
-d '{
"to": ["+1234567890"],
"template": {
"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8"
}
}'const response = await client.messages.send({
to: ['+1234567890'],
template: {
id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8'
}
}, {
idempotencyKey: 'otp_user_123_20250115'
});response = client.messages.send(
to=["+1234567890"],
template={
"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8"
},
idempotency_key="otp_user_123_20250115"
)response, err := client.Messages.Send(
context.Background(),
sentdm.MessageSendParams{
To: []string{"+1234567890"},
Template: sentdm.MessageSendParamsTemplate{
ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"),
},
},
option.WithIdempotencyKey("otp_user_123_20250115"),
)MessageSendParams params = MessageSendParams.builder()
.addTo("+1234567890")
.template(MessageSendParams.Template.builder()
.id("7ba7b820-9dad-11d1-80b4-00c04fd430c8")
.build())
.build();
var response = client.messages().send(params, RequestOptions.builder()
.idempotencyKey("otp_user_123_20250115")
.build());MessageSendParams parameters = new()
{
To = new List<string> { "+1234567890" },
Template = new MessageSendParamsTemplate
{
Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8"
}
};
var response = await client.Messages.Send(
parameters,
new RequestOptions { IdempotencyKey = "otp_user_123_20250115" }
);$result = $client->messages->send(
to: ['+1234567890'],
template: [
'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8'
],
idempotencyKey: 'otp_user_123_20250115'
);result = sent_dm.messages.send(
to: ["+1234567890"],
template: {
id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8"
},
idempotency_key: "otp_user_123_20250115"
)Keys are cached for 24 hours. Reusing a key returns the original response without sending a duplicate message.
Handle Failures
Classify errors before reacting: back off on 429 rate limits, fix rather than retry 4xx validation failures, and retry 5xx with exponential backoff. The Error Handling guide owns these patterns, including retry loops, circuit breakers, and channel fallbacks.
Track Status with Webhooks
Don't poll for status. Configure webhooks and update the message records you stored in Store Message IDs as status events arrive. Refer to Message Status Tracking for handler examples, payload shapes, and the full status lifecycle.
RCS Best Practices
When sending on RCS, a few channel-specific considerations improve delivery and engagement:
- Use suggestion chips for interaction: Template buttons render as tappable suggestion chips below the message: quick reply, open URL, and dial number actions. Sent maps up to four template buttons to chips and truncates chip labels to 25 characters, so keep button text short.
- Don't add manual opt-out text: Every RCS message automatically includes a STOP suggestion chip. Adding your own opt-out footer is redundant and clutters the message.
- Handle inbound opt-out keywords: Taps on the built-in STOP chip arrive as
message.receivedwebhook events, and Sent's consent engine flips the contact'sopt_outflag automatically. Refer to RCS Suggestion Chips for how chip taps and keywords are processed, and mirror the suppression in your own datastore. - Always include SMS fallback: Use
"channel": ["rcs", "sms"]rather than["rcs"]alone. This ensures delivery to recipients on devices or carriers without RCS support.
Rich cards and carousel cards are part of the RCS standard but are not yet available through Sent. RCS messages currently render as text plus suggestion chips.
Estimate Costs Before a Bulk Send
Rates vary by channel, by destination country, and on SMS, by segment count: each segment of a multi-segment body bills as a separate message (refer to SMS Length & Cost). The API doesn't expose a rate card, so build an estimate from two numbers it does give you.
Count messages with a sandbox request. Add sandbox: true to the bulk request. The response echoes one recipient entry per (recipient, channel) pair without sending anything, so the length of recipients is your exact message count. List channels explicitly; if you omit channel (auto-detect), entries return channel: null and you can't attribute the count to a channel's rate. Testing & Debugging documents sandbox mode itself.
Read real prices from a pilot send. After a real send, GET /v3/messages/{id} returns price (channel cost) and active_contact_price (markup applied on top) for that message. Send the template to a small pilot group in the target country, average the sum of both fields, and multiply by the message count:
// 1. Count messages without sending - sandbox echoes the real fan-out
const test = await client.messages.send({
to: recipients, // the full bulk list
channel: ['sms'], // explicit channel keeps the count attributable
template: { id: templateId },
sandbox: true
});
const messageCount = test.data.recipients.length;
// 2. Average actual per-message price from an earlier pilot send
const pilot = await Promise.all(
pilotMessageIds.map((id) => client.messages.retrieveStatus(id))
);
const perMessage =
pilot.reduce(
(sum, m) => sum + (m.data.price ?? 0) + (m.data.active_contact_price ?? 0),
0
) / pilot.length;
console.log(
`~${messageCount} messages x $${perMessage.toFixed(4)} each = ~$${(messageCount * perMessage).toFixed(2)}`
);Sandbox message_id values are simulated and reference no stored message, so price lookups only work for messages from real sends.
Related Guides
Sending Messages
Core message sending guide with all SDK examples
Response Handling
Detailed guide to handling responses and errors
Batch Operations
High-volume sending and contact imports
Error Handling
Retries, backoff, circuit breakers, and fallback strategies
Testing & Debugging
Sandbox mode, webhook testing, and debugging failed messages
SMS Length & Cost
How SMS segments are counted and billed
Webhooks
Real-time delivery notifications
Sending Messages
How to send SMS, WhatsApp, and RCS messages with the Sent API: template sends, free-form text without a template, channel selection, and error handling.
Message Response Handling
Parse Sent API v3 message-send responses: the success/data/error envelope, per-recipient message IDs, validation and auth error shapes, and async failures.