Batch Operations
This guide shows you how to send high-volume campaigns and import contacts in bulk while staying inside Sent's API limits.
Overview
When dealing with large volumes:
- Batch message sending - Send to up to 1000 recipients in one request
- Bulk contact import - Import thousands of contacts efficiently
- Rate limit management - Stay within API limits
- Queue-based processing - Process large jobs asynchronously
Large-Scale Sending Flow
Batch Message Sending
Multiple Recipients
Send the same message to up to 1000 recipients in one 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",
"+1987654321",
"+1555555555"
],
"template": {
"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"parameters": {
"announcement": "New feature launched!"
}
},
"channel": ["sms", "whatsapp", "rcs"]
}'const response = await client.messages.send({
to: [
'+1234567890',
'+1987654321',
'+1555555555'
// Up to 1000 recipients
],
template: {
id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
parameters: {
announcement: 'New feature launched!'
}
}
});
// Track individual message IDs
const messageIds = response.data.recipients.map(r => r.message_id);
console.log(`Sent ${messageIds.length} messages`);response = client.messages.send(
to=[
"+1234567890",
"+1987654321",
"+1555555555"
# Up to 1000 recipients
],
template={
"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"parameters": {
"announcement": "New feature launched!"
}
}
)
message_ids = [r.message_id for r in response.data.recipients]
print(f"Sent {len(message_ids)} messages")response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{
To: []string{
"+1234567890",
"+1987654321",
"+1555555555",
// Up to 1000 recipients
},
Channel: []string{"sms", "whatsapp", "rcs"},
Template: sentdm.MessageSendParamsTemplate{
ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"),
Parameters: map[string]interface{}{
"announcement": "New feature launched!",
},
},
})
// Track individual message IDs
messageIDs := make([]string, len(response.Data.Recipients))
for i, r := range response.Data.Recipients {
messageIDs[i] = r.MessageID
}
fmt.Printf("Sent %d messages\n", len(messageIDs))MessageSendParams params = MessageSendParams.builder()
.addTo("+1234567890")
.addTo("+1987654321")
.addTo("+1555555555")
.addChannel("sms")
.addChannel("whatsapp")
.addChannel("rcs")
.template(MessageSendParams.Template.builder()
.id("7ba7b820-9dad-11d1-80b4-00c04fd430c8")
.parameters(MessageSendParams.Template.Parameters.builder()
.putAdditionalProperty("announcement", JsonValue.from("New feature launched!"))
.build())
.build())
.build();
var response = client.messages().send(params);
// Track individual message IDs
List<String> messageIds = response.data().recipients().stream()
.map(r -> r.messageId())
.toList();
System.out.println("Sent " + messageIds.size() + " messages");MessageSendParams parameters = new()
{
To = new List<string> {
"+1234567890",
"+1987654321",
"+1555555555"
// Up to 1000 recipients
},
Channels = new List<string> { "sms", "whatsapp", "rcs" },
Template = new MessageSendParamsTemplate
{
Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
Parameters = new Dictionary<string, string>
{
{ "announcement", "New feature launched!" }
}
}
};
var response = await client.Messages.Send(parameters);
// Track individual message IDs
var messageIds = response.Data.Recipients.Select(r => r.MessageId).ToList();
Console.WriteLine($"Sent {messageIds.Count} messages");$result = $client->messages->send(
to: [
'+1234567890',
'+1987654321',
'+1555555555'
// Up to 1000 recipients
],
template: [
'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
'parameters' => [
'announcement' => 'New feature launched!'
]
],
channels: ['sms', 'whatsapp', 'rcs']
);
// Track individual message IDs
$message_ids = array_map(fn($r) => $r->message_id, $result->data->recipients);
echo "Sent " . count($message_ids) . " messages\n";result = sent_dm.messages.send_(
to: [
"+1234567890",
"+1987654321",
"+1555555555"
# Up to 1000 recipients
],
template: {
id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
parameters: {
announcement: "New feature launched!"
}
},
channels: ["sms", "whatsapp", "rcs"]
)
# Track individual message IDs
message_ids = result.data.recipients.map(&:message_id)
puts "Sent #{message_ids.length} messages"Batch Response
{
"success": true,
"data": {
"status": "QUEUED",
"template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"template_name": "product_announcement",
"recipients": [
{ "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+1234567890", "channel": "sms" },
{ "message_id": "8ba7b831-9dad-11d1-80b4-00c04fd430c8", "to": "+1987654321", "channel": "whatsapp" },
{ "message_id": "8ba7b832-9dad-11d1-80b4-00c04fd430c8", "to": "+1555555555", "channel": "sms" }
]
},
"error": null,
"meta": {
"request_id": "req_batch_001",
"timestamp": "2026-03-04T11:28:25.2096416+00:00",
"version": "v3"
}
}Batch requests count against rate limits per request (not per recipient). Each POST /v3/messages call counts as one request toward the 200 req/min limit, regardless of how many recipients are included.
Large-Scale Sending (10,000+ Recipients)
For campaigns with tens of thousands of recipients, process the list in slices of 1000 with a delay between batches:
// batchProcessor.ts
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function sendCampaign(recipients: string[], templateId: string) {
const BATCH_SIZE = 1000; // Maximum recipients per request
const DELAY_BETWEEN_BATCHES = 1000; // 1 second
const results = {
sent: 0,
failed: 0,
messageIds: []
};
// Process in batches
for (let i = 0; i < recipients.length; i += BATCH_SIZE) {
const batch = recipients.slice(i, i + BATCH_SIZE);
try {
const response = await client.messages.send({
to: batch,
template: { id: templateId }
});
// The 202 response confirms acceptance; track delivery via webhooks
results.sent += response.data.recipients.length;
results.messageIds.push(...response.data.recipients.map(r => r.message_id));
console.log(`Batch ${i / BATCH_SIZE + 1} complete: ${batch.length} messages`);
// Rate limiting delay (except for last batch)
if (i + BATCH_SIZE < recipients.length) {
await sleep(DELAY_BETWEEN_BATCHES);
}
} catch (error) {
console.error(`Batch ${i / BATCH_SIZE + 1} failed:`, error);
results.failed += batch.length;
}
}
return results;
}
// Usage
const recipients = await getAllCustomerPhoneNumbers(); // 5000 numbers
const results = await sendCampaign(recipients, '7ba7b820-9dad-11d1-80b4-00c04fd430c8');
console.log(`Campaign complete: ${results.sent} sent, ${results.failed} failed`);Bulk Contact Import
CSV Import
Format your CSV file:
phone_number
+1234567890
+1987654321
+1555555555Import script:
import { parse } from 'csv-parse';
import fs from 'fs';
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function importContacts(csvPath: string) {
const parser = fs.createReadStream(csvPath).pipe(parse({
columns: true,
skip_empty_lines: true
}));
const results = { created: 0, failed: 0, errors: [] };
const BATCH_SIZE = 50;
let batch = [];
for await (const record of parser) {
batch.push({
phoneNumber: record.phone_number
});
if (batch.length >= BATCH_SIZE) {
const result = await processBatch(batch);
results.created += result.created;
results.failed += result.failed;
results.errors.push(...result.errors);
batch = [];
// Rate limit protection
await sleep(100);
}
}
// Process remaining
if (batch.length > 0) {
const result = await processBatch(batch);
results.created += result.created;
results.failed += result.failed;
}
return results;
}
async function processBatch(batch: any[]) {
const results = { created: 0, failed: 0, errors: [] };
await Promise.all(batch.map(async (contact) => {
try {
await client.contacts.create(contact);
results.created++;
} catch (error) {
results.failed++;
results.errors.push({ contact, error: error.message });
}
}));
return results;
}Rate Limit Management
Understanding Limits
| Endpoint | Limit | Window |
|---|---|---|
POST /v3/messages | 200 requests | 1 minute |
POST /v3/contacts | 200 requests | 1 minute |
GET /v3/* | 200 requests | 1 minute |
Limits count per request, not per recipient, and all API keys on an account share one pool. A stricter tier of 10 requests per minute applies only to two sensitive webhook endpoints, POST /v3/webhooks/{id}/rotate-secret and POST /v3/webhooks/{id}/test, neither of which is involved in batch sending. Refer to the Rate Limits reference for the full per-endpoint table and rate limit headers.
Staying Under the Limit
Space your batch requests so total throughput stays below 200 requests per minute; the one-second delay between batches in the Large-Scale Sending loop keeps you at a safe 60 requests per minute. If you need finer-grained control, refer to the Rate Limits reference for ready-made throttle and backoff implementations in TypeScript, Python, and Go.
Handling Rate Limit Errors
If a request returns 429, honor the Retry-After header before retrying:
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function sendWithBackoff(phoneNumber: string, templateId: string, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.messages.send({ to: [phoneNumber], template: { id: templateId } });
} catch (error) {
if (error.status === 429) {
// Get retry-after header or use exponential backoff
const retryAfter = error.headers['retry-after'] || Math.pow(2, attempt);
console.log(`Rate limited. Waiting ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}Queue-Based Processing
For sustained high volume, run sends through a persistent job queue (for example BullMQ, Sidekiq, or Celery) instead of a single in-process loop. The queue and worker mechanics belong to your infrastructure; the Sent-specific requirements are:
- One job per batch - Group up to 1000 recipients per job so each job makes a single
POST /v3/messagescall. - Bounded concurrency - Cap workers so combined throughput stays under 200 requests per minute.
- Idempotent jobs - Set an idempotency key per job so queue retries don't double-send; see Retry Safely with Idempotency Keys.
- Persist message IDs - Store the
message_idvalues from each202response so webhook events can be matched back to jobs. - Watch queue depth - Alert when jobs accumulate faster than workers drain them.
Reuse a single SDK client instance across jobs so HTTP connections are pooled.
Monitoring Bulk Operations
Log progress per batch from the counts in each 202 response, as the Large-Scale Sending loop does. For delivery outcomes, subscribe to the webhook events message.delivered, message.failed, message.filtered, and message.blocked instead of polling GET /v3/messages/{id} for every message; see Status Tracking.
Best Practices
Handle Partial Failures
A 202 response confirms acceptance, not delivery. Validation is all-or-nothing (a 400 rejects the whole request), so every recipient in an accepted batch gets a message_id, but individual messages can still end FAILED, FILTERED (recipient opted out), or BLOCKED (insufficient balance) asynchronously. Persist the accepted IDs and reconcile them against webhook events:
const recipients = ['+14155551234', '+14155555678'];
const response = await client.messages.send({
to: recipients,
template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' }
});
// Persist each accepted message so your webhook handler can mark it
// DELIVERED, FAILED, FILTERED, or BLOCKED as events arrive
const accepted = response.data.recipients.map((r) => ({
messageId: r.message_id,
to: r.to,
status: 'PENDING'
}));
console.log(`Accepted ${accepted.length} messages`);Retry Safely with Idempotency Keys
Give every batch a deterministic idempotency key so a retried request (after a timeout, 429, or worker crash) replays the original response instead of double-sending:
const recipients = ['+14155551234', '+14155555678'];
const campaignId = 'spring_launch';
const batchNumber = 1;
const response = await client.messages.send(
{
to: recipients,
template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' }
},
{ idempotencyKey: `campaign_${campaignId}_${batchNumber}` }
);Refer to the Idempotency reference for key format and replay behavior.
Start Small
Before launching a full campaign, run the batch pipeline against a small slice, for example sendCampaign(recipients.slice(0, 10), templateId), and confirm delivery through webhooks before committing the full list.