Sending Messages

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": { "id": "…", "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, or rcs. Omit it and Sent picks the channel from the template. See Channels.
  • template — reference an approved template by id or name, plus its parameters. See Templates.
  • sandboxtrue for 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)
end

Map 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.

// 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:

In plain English: the 200 you return right after send only means "Sent accepted the request" — the real delivery outcome shows up later, on its own, over the webhook at the bottom. You cannot confirm delivery from the send response alone.

Next steps

On this page