Message Best Practices

Production-ready patterns for reliable, cost-effective messaging.

Test Mode

Validate requests without sending real messages by adding test_mode: true to your request:

curl -X POST "https://api.sent.dm/v3/messages" \
  -H "x-api-key: $SENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["+1234567890"],
    "template": {
      "id": "tmpl_1234567890"
    },
    "test_mode": true
  }'
const response = await client.messages.send({
  to: ['+1234567890'],
  template: {
    id: 'tmpl_1234567890'
  },
  testMode: true
});

// Check for test mode header
console.log(response.headers['x-test-mode']); // 'true'
response = client.messages.send(
    to=["+1234567890"],
    template={
        "id": "tmpl_1234567890"
    },
    test_mode=True
)

# Check for test mode header
print(response.headers.get('x-test-mode'))  # 'true'
response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{
    To: []string{"+1234567890"},
    Template: sentdm.MessageSendParamsTemplate{
        ID: sentdm.String("tmpl_1234567890"),
    },
    TestMode: sentdm.Bool(true),
})

// Check for test mode header
fmt.Println(response.Headers["X-Test-Mode"]) // "true"
MessageSendParams params = MessageSendParams.builder()
    .addTo("+1234567890")
    .template(MessageSendParams.Template.builder()
        .id("tmpl_1234567890")
        .build())
    .testMode(true)
    .build();

var response = client.messages().send(params);
// Check for test mode header
System.out.println(response.headers().get("X-Test-Mode")); // "true"
MessageSendParams parameters = new()
{
    To = new List<string> { "+1234567890" },
    Template = new MessageSendParamsTemplate
    {
        Id = "tmpl_1234567890"
    },
    TestMode = true
};

var response = await client.Messages.SendAsync(parameters);
// Check for test mode header
Console.WriteLine(response.Headers["X-Test-Mode"]); // "true"
$result = $client->messages->send(
    to: ['+1234567890'],
    template: [
        'id' => 'tmpl_1234567890'
    ],
    testMode: true
);

// Check for test mode header
echo $result->headers['X-Test-Mode']; // 'true'
result = sent_dm.messages.send(
  to: ["+1234567890"],
  template: {
    id: "tmpl_1234567890"
  },
  test_mode: true
)

# Check for test mode header
puts result.headers["X-Test-Mode"] # "true"

The API validates your request and returns a realistic fake response without executing any side effects:

  • No database writes
  • No messages sent
  • No external API calls

Look for the X-Test-Mode: true header in the response.

When to Use Test Mode

  • Validate template variables - Ensure placeholders resolve correctly
  • Check recipient formatting - Verify phone numbers are valid
  • Verify account permissions - Confirm API key has required access
  • Test in CI/CD pipelines - Run integration tests without sending real messages
  • Estimate costs - Calculate potential spend before bulk sends

Cost Estimation Example

// Test a bulk send to estimate costs
const testResponse = await client.messages.send({
  to: ['+1234567890', '+1987654321', '+1555555555'], // 3 recipients
  template: { id: 'tmpl_promo' },
  channel: ['whatsapp', 'sms'], // Will create 6 messages
  testMode: true
});

console.log(`Would send ${testResponse.data.recipients.length} messages`);
// Calculate cost based on channel/country rates
# Test a bulk send to estimate costs
test_response = client.messages.send(
    to=["+1234567890", "+1987654321", "+1555555555"],  # 3 recipients
    template={"id": "tmpl_promo"},
    channel=["whatsapp", "sms"],  # Will create 6 messages
    test_mode=True
)

print(f"Would send {len(test_response.data.recipients)} messages")
# Calculate cost based on channel/country rates
// Test a bulk send to estimate costs
testResponse, _ := client.Messages.Send(context.Background(), sentdm.MessageSendParams{
    To: []string{"+1234567890", "+1987654321", "+1555555555"}, // 3 recipients
    Template: sentdm.MessageSendParamsTemplate{
        ID: sentdm.String("tmpl_promo"),
    },
    Channel:  []string{"whatsapp", "sms"}, // Will create 6 messages
    TestMode: sentdm.Bool(true),
})

fmt.Printf("Would send %d messages\n", len(testResponse.Data.Recipients))
// Calculate cost based on channel/country rates

Best Practices

1. Always Store Message IDs

Store message IDs for tracking delivery status:

// Store in your database - note: one entry per recipient
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 - note: one entry per recipient
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 - note: one entry per recipient
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 - note: one entry per recipient
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 - note: one entry per recipient
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 - note: one entry per recipient
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 - note: one entry per recipient
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
  )
end

2. Implement Idempotency

For critical messages (OTP, payments), use idempotency keys via the Idempotency-Key header:

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": "tmpl_otp_template"
    }
  }'
const response = await client.messages.send({
  to: ['+1234567890'],
  template: {
    id: 'tmpl_otp_template'
  }
}, {
  idempotencyKey: 'otp_user_123_20250115'
});
response = client.messages.send(
    to=["+1234567890"],
    template={
        "id": "tmpl_otp_template"
    },
    idempotency_key="otp_user_123_20250115"
)
response, err := client.Messages.Send(
    context.Background(),
    sentdm.MessageSendParams{
        To: []string{"+1234567890"},
        Template: sentdm.MessageSendParamsTemplate{
            ID: sentdm.String("tmpl_otp_template"),
        },
    },
    option.WithIdempotencyKey("otp_user_123_20250115"),
)
MessageSendParams params = MessageSendParams.builder()
    .addTo("+1234567890")
    .template(MessageSendParams.Template.builder()
        .id("tmpl_otp_template")
        .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 = "tmpl_otp_template"
    }
};

var response = await client.Messages.SendAsync(
    parameters,
    new RequestOptions { IdempotencyKey = "otp_user_123_20250115" }
);
$result = $client->messages->send(
    to: ['+1234567890'],
    template: [
        'id' => 'tmpl_otp_template'
    ],
    idempotencyKey: 'otp_user_123_20250115'
);
result = sent_dm.messages.send(
  to: ["+1234567890"],
  template: {
    id: "tmpl_otp_template"
  },
  idempotency_key: "otp_user_123_20250115"
)

Keys are cached for 24 hours. Reusing a key returns the original response without sending a duplicate message.

3. Handle Failures Gracefully

try {
  const response = await client.messages.send({...});
} catch (error) {
  if (error.status === 429) {
    // Rate limited - implement backoff
    await delay(1000);
    return retrySend();
  }
  if (error.code === 'BUSINESS_003') {
    // Insufficient balance - alert billing team
    await alertBillingTeam();
  }
  // Log for investigation
  logger.error('Message send failed', { error, recipient });
}
from sent_dm.errors import RateLimitError, BusinessError

try:
    response = client.messages.send(...)
except RateLimitError as e:
    # Rate limited - implement backoff
    time.sleep(1)
    return retry_send()
except BusinessError as e:
    if e.code == 'BUSINESS_003':
        # Insufficient balance - alert billing team
        alert_billing_team()
    # Log for investigation
    logger.error(f"Message send failed: {e}")
response, err := client.Messages.Send(context.Background(), params)
if err != nil {
    if sentdm.IsRateLimitError(err) {
        // Rate limited - implement backoff
        time.Sleep(time.Second)
        return retrySend()
    }
    if sentdm.IsBusinessError(err, "BUSINESS_003") {
        // Insufficient balance - alert billing team
        alertBillingTeam()
    }
    // Log for investigation
    log.Printf("Message send failed: %v", err)
}
try {
    var response = client.messages().send(params);
} catch (RateLimitException e) {
    // Rate limited - implement backoff
    Thread.sleep(1000);
    return retrySend();
} catch (BusinessException e) {
    if (e.getCode().equals("BUSINESS_003")) {
        // Insufficient balance - alert billing team
        alertBillingTeam();
    }
    // Log for investigation
    logger.error("Message send failed", e);
}
try
{
    var response = await client.Messages.SendAsync(parameters);
}
catch (RateLimitException ex)
{
    // Rate limited - implement backoff
    await Task.Delay(1000);
    return await RetrySendAsync();
}
catch (BusinessException ex) when (ex.Code == "BUSINESS_003")
{
    // Insufficient balance - alert billing team
    await AlertBillingTeamAsync();
}
catch (Exception ex)
{
    // Log for investigation
    logger.LogError(ex, "Message send failed");
}
use SentDM\Exceptions\RateLimitException;
use SentDM\Exceptions\BusinessException;

try {
    $result = $client->messages->send(...);
} catch (RateLimitException $e) {
    // Rate limited - implement backoff
    sleep(1);
    return retrySend();
} catch (BusinessException $e) {
    if ($e->getCode() === 'BUSINESS_003') {
        // Insufficient balance - alert billing team
        alertBillingTeam();
    }
    // Log for investigation
    error_log("Message send failed: " . $e->getMessage());
}
begin
  result = sent_dm.messages.send(...)
rescue Sentdm::RateLimitError => e
  # Rate limited - implement backoff
  sleep(1)
  return retry_send()
rescue Sentdm::BusinessError => e
  if e.code == 'BUSINESS_003'
    # Insufficient balance - alert billing team
    alert_billing_team()
  end
  # Log for investigation
  logger.error("Message send failed: #{e.message}")
end

4. Use Webhooks for Status Updates

Don't poll for status. Set up webhooks instead:

// Webhook handler
app.post('/webhooks/sent', async (req, res) => {
  const { id, status, to, channel } = req.body;

  await db.messages.update({ sentMessageId: id }, {
    status: status,
    channel: channel,
    deliveredAt: status === 'DELIVERED' ? new Date() : null
  });

  res.sendStatus(200);
});
from flask import Flask, request, jsonify

@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
    data = request.json

    db.messages.update(
        {'sent_message_id': data['id']},
        {
            'status': data['status'],
            'channel': data['channel'],
            'delivered_at': datetime.now() if data['status'] == 'DELIVERED' else None
        }
    )

    return jsonify({'status': 'ok'}), 200
import "github.com/gin-gonic/gin"

func webhookHandler(c *gin.Context) {
    var payload struct {
        ID      string `json:"id"`
        Status  string `json:"status"`
        To      string `json:"to"`
        Channel string `json:"channel"`
    }

    if err := c.BindJSON(&payload); err != nil {
        c.Status(400)
        return
    }

    db.Messages.Update(MessageUpdate{
        SentMessageID: payload.ID,
        Status:        payload.Status,
        Channel:       payload.Channel,
    })

    c.Status(200)
}
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def sent
    payload = params.permit(:id, :status, :to, :channel)

    Message.find_by(sent_message_id: payload[:id])&.update(
      status: payload[:status],
      channel: payload[:channel],
      delivered_at: payload[:status] == 'DELIVERED' ? Time.current : nil
    )

    head :ok
  end
end

Cost Considerations

Pricing factors:

  • Channel: WhatsApp is often cheaper than SMS
  • Destination country: International rates vary
  • Message length: SMS over 160 chars = multiple segments
  • Template type: Utility vs Marketing pricing

Use test mode to validate costs before sending:

curl -X POST "https://api.sent.dm/v3/messages" \
  -H "x-api-key: $SENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["+1234567890"],
    "template": {
      "id": "tmpl_123"
    },
    "test_mode": true
  }'
const response = await client.messages.send({
  to: ['+1234567890'],
  template: { id: 'tmpl_123' },
  testMode: true
});

// Validated without sending
console.log('Would send to:', response.data.recipients);
response = client.messages.send(
    to=["+1234567890"],
    template={"id": "tmpl_123"},
    test_mode=True
)

# Validated without sending
print(f"Would send to: {response.data.recipients}")
response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{
    To: []string{"+1234567890"},
    Template: sentdm.MessageSendParamsTemplate{
        ID: sentdm.String("tmpl_123"),
    },
    TestMode: sentdm.Bool(true),
})

// Validated without sending
fmt.Printf("Would send to: %v\n", response.Data.Recipients)
MessageSendParams params = MessageSendParams.builder()
    .addTo("+1234567890")
    .template(MessageSendParams.Template.builder()
        .id("tmpl_123")
        .build())
    .testMode(true)
    .build();

var response = client.messages().send(params);
// Validated without sending
System.out.println("Would send to: " + response.data().recipients());
MessageSendParams parameters = new()
{
    To = new List<string> { "+1234567890" },
    Template = new MessageSendParamsTemplate { Id = "tmpl_123" },
    TestMode = true
};

var response = await client.Messages.SendAsync(parameters);
// Validated without sending
Console.WriteLine($"Would send to: {response.Data.Recipients.Count} recipients");
$result = $client->messages->send(
    to: ['+1234567890'],
    template: ['id' => 'tmpl_123'],
    testMode: true
);

// Validated without sending
echo "Would send to: " . count($result->data->recipients) . " recipients\n";
result = sent_dm.messages.send(
  to: ["+1234567890"],
  template: { id: "tmpl_123" },
  test_mode: true
)

# Validated without sending
puts "Would send to: #{result.data.recipients.length} recipients"

On this page