Sending Messages with the Sent SDK: The Outbound Path
Sending is the first half of your integration: your app calls Sent, Sent hands back a set of
recipients, and you record them. The whole outbound path is one SDK call (messages.send)
wrapped in a thin service method and a controller that does nothing but validate, delegate,
and map. Get that seam right and everything downstream (status tracking, error handling,
tests) falls out cleanly.
Before you send anything, understand the concepts: sending messages, channels, and templates. Sent is template-first: you send an approved template with parameters, not free-form text.
The request shape
Every backend maps one canonical request to messages.send:
{
"to": ["+14155550123"],
"channel": ["sms"],
"template": { "name": "order_update", "parameters": { "1": "Ada" } },
"sandbox": false
}to: an array of E.164 numbers. One call can fan out to many recipients.channel: optional array;sms,whatsapp, orrcs. Omit it and Sent picks the channel from the template. See Channels.template: reference an approved template byname(as here) or byid(one selector is enough), plus itsparameters. See Templates.sandbox:truefor a dry run that validates and returns a shaped response without actually delivering. Great for wiring up the path end-to-end before going live.
The service method
Put the SDK call behind one method. It takes your typed input, calls messages.send, and maps
the snake_case SDK response into your own camelCase contract, nothing else. Controllers never
see an SDK type; tests mock this one method.
The critical mapping: the response carries data.recipients[], and each recipient's
message_id becomes your id. Also lift data.status, data.template_id, and
data.template_name to the top level.
// services/sent.service.ts
async send(input: SentMessageInput): Promise<SendCanonicalResponse> {
try {
const response = await this.client.messages.send({
to: input.to,
channel: input.channel,
template: input.template,
sandbox: input.sandbox,
});
const data = response.data ?? {};
const recipients = data.recipients ?? [];
return {
status: data.status,
templateId: data.template_id,
templateName: data.template_name,
recipients: recipients.map((r) => ({
id: r.message_id, // ← message_id → id
to: r.to,
channel: r.channel,
body: r.body,
})),
};
} catch (error) {
throw this.toApiError(error, 'Failed to send message');
}
}# app/services/sent_service.py
async def send_canonical(self, request: CanonicalSendRequest) -> CanonicalSendResponse:
template: dict[str, object] = {"parameters": request.template.parameters}
if request.template.id is not None:
template["id"] = request.template.id
if request.template.name is not None:
template["name"] = request.template.name
response = await self._client.messages.send(
to=request.to,
template=template,
channel=request.channel,
sandbox=request.sandbox,
)
data = response.data
recipients = data.recipients if data and data.recipients else []
return CanonicalSendResponse(
status=getattr(data, "status", None),
templateId=getattr(data, "template_id", None),
templateName=getattr(data, "template_name", None),
recipients=[
CanonicalRecipient(
id=r.message_id, # ← message_id → id
to=r.to,
channel=r.channel,
body=r.body,
)
for r in recipients
],
)// internal/models/contract.go — map the SDK response to the contract shape.
func NewSendMessageContractResponse(data sentdm.MessageSendResponseData) SendMessageContractResponse {
recipients := make([]MessageRecipient, 0, len(data.Recipients))
for _, r := range data.Recipients {
recipients = append(recipients, MessageRecipient{
ID: r.MessageID, // ← message_id → id
To: r.To,
Channel: r.Channel,
Body: r.Body,
})
}
return SendMessageContractResponse{
Status: data.Status,
TemplateID: data.TemplateID,
TemplateName: data.TemplateName,
Recipients: recipients,
}
}
// internal/services/sentclient.go — the thin wrapper (note: Sandbox, not TestMode).
func (c *SentClient) SendMessageRaw(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return c.client.Messages.Send(ctx, params)
}// service/impl/CanonicalMessageServiceImpl.java
public CanonicalSendResponse send(CanonicalSendRequest request) {
SentClient sentClient = clientProvider.client();
MessageSendResponse response = sentClient.messages().send(buildParams(request));
var data = response.data().orElseThrow(() -> new RuntimeException("Empty response data"));
String status = data.status().orElse("QUEUED");
var recipients = data.recipients().orElse(List.of()).stream()
.map(r -> new CanonicalSendResponse.Recipient(
r.messageId().orElse(""), // ← message_id → id
r.to().orElse(""),
r.channel().orElse(null),
r.body().orElse(null)))
.toList();
return new CanonicalSendResponse(status,
data.templateId().orElse(null), data.templateName().orElse(null), recipients);
}// Services/SentDmService.cs
public async Task<CanonicalSendResponse> SendCanonicalAsync(
CanonicalSendRequest request, CancellationToken ct = default)
{
var parameters = new MessageSendParams
{
Template = new Sentdm.Models.Messages.Template
{
ID = request.Template.Id,
Name = request.Template.Name,
Parameters = request.Template.Parameters ?? new Dictionary<string, string>()
},
To = request.To.ToArray(),
Channel = request.Channel is { Count: > 0 } ? request.Channel.ToArray() : null,
Sandbox = request.Sandbox
};
var result = await Client.Messages.Send(parameters, ct).ConfigureAwait(false);
var data = result.Data;
var recipients = (data?.Recipients ?? Array.Empty<Recipient>())
.Select(r => new CanonicalRecipient(
r.MessageID ?? string.Empty, // ← message_id → id
r.To ?? string.Empty, r.Channel ?? string.Empty, r.Body ?? string.Empty))
.ToList();
return new CanonicalSendResponse(
data?.Status ?? string.Empty, data?.TemplateID, data?.TemplateName, recipients);
}// app/Services/SentDM/SentDMService.php
public function send(array $to, ?array $channel, array $template, bool $sandbox = false): array
{
$response = $this->client->messages->send(
to: $to,
template: [
'id' => $template['id'] ?? null,
'name' => $template['name'] ?? null,
'parameters' => $template['parameters'] ?? [],
],
channel: $channel,
sandbox: $sandbox,
);
$data = $response->data;
$recipients = array_map(fn ($r) => [
'id' => $r->messageID, // ← message_id → id
'to' => $r->to,
'channel' => $r->channel,
'body' => $r->body,
], $data->recipients ?? []);
return [
'status' => $data->status,
'templateId' => $data->templateID,
'templateName' => $data->templateName,
'recipients' => $recipients,
];
}# app/services/sent_dm/send_message_service.rb
def call
validate!
response = client.messages.send_(
to: recipients, # an array — one call can fan out to many recipients
template: { id: template_id, name: template_name, parameters: variables },
channel: channels # the Ruby SDK parameter is `channel:` (an array)
)
success(
status: response.data.status,
recipients: response.data.recipients.map do |r|
{ id: r.message_id, to: r.to, channel: r.channel, body: r.body } # ← message_id → id
end
)
rescue ApplicationService::ValidationError => e
failure(:validation_error, e.message)
rescue StandardError => e
handle_api_error(e)
endMap at the edge, every time. The SDK speaks snake_case (message_id, template_id); your
contract speaks camelCase (id, templateId). Never leak raw SDK types into controllers or
clients. The mapping is what keeps your surface stable when the SDK changes. See the full
table in the reference architecture.
The controller
The controller is deliberately thin: build the per-request client, validate the body, call the
service, persist each recipient into the message store, and return the mapped response. That's
it. No business logic. validateBody and asyncHandler are the
shared helpers from project setup.
// controllers/messages.controller.ts
router.post(
'/',
validateBody(SendCanonicalSchema),
asyncHandler(async (req, res) => {
const dto = req.body as SendCanonicalDto;
const { sentService } = servicesForRequest(req); // client built from Bearer key
const result = await sentService.send({
to: dto.to,
channel: dto.channel,
template: dto.template,
sandbox: dto.sandbox,
});
// Persist each recipient so the webhook receiver can advance its status later.
for (const r of result.recipients) {
if (r.id) {
messageStore.record({
id: r.id,
to: r.to,
channel: r.channel,
templateId: result.templateId,
templateName: result.templateName,
status: result.status,
});
}
}
res.status(200).json(result);
}),
);Notice servicesForRequest(req). The SDK client is built per request from the caller's
Authorization: Bearer key, never a boot-time singleton or an env var. This is the single
most important pattern in the whole guide; see Authentication.
Channels
Pick channels per send with the channel array, or omit it and let the template's channel
decide.
sms: plain SMS. Universally reachable; strict on template content.whatsapp: rich templates, buttons, media. Requires approved WhatsApp templates.rcs: richer than SMS where carriers and handsets support it.
Passing more than one channel lets Sent route across them. Read Channels for how routing and fallback work.
Sandbox sends
Set sandbox: true (the request field is named Sandbox in the Go/C# SDKs too) to exercise
the entire path (validation, mapping, store, and your response shape) without delivering a
real message or spending credit. Flip it off for production traffic.
{ "to": ["+14155550123"], "template": { "name": "order_update" }, "sandbox": true }What you get back, and what you don't
A successful send returns immediately with a non-terminal status, typically QUEUED:
{
"status": "QUEUED",
"templateId": "…",
"templateName": "order_update",
"recipients": [{ "id": "…", "to": "+14155550123", "channel": "sms", "body": "…" }]
}A 200 from messages.send means Sent accepted the message, not that it was delivered.
The final outcome (SENT, DELIVERED, READ, or FAILED) arrives asynchronously over a
webhook, minutes later. That's why the controller records each recipient into the message
store on the way out: so the inbound webhook can advance its status. Close that loop in
Status tracking.
The send-then-track flow, end to end:
Next steps
About Per-Request Credentials
Why the Sent API key is a per-request credential, never a boot-time singleton. Who sends the bearer key in each setup, and why isolation is structural.
Managing Contacts & Templates
Service wrappers for contacts and templates: the camelCase contract mapping, looking up templates by id or name, and decoupling your app from hard-coded IDs.