Messages API
Send text, media, and WhatsApp template messages via any supported channel.
Send messages to contacts via WhatsApp, Instagram, or Messenger. There are two distinct endpoints depending on what you're sending.
Two separate endpoints:
- Standard text/media messages →
POST /v1/messages - WhatsApp template messages →
POST /v1/messages/templates
Do not mix them. Template fields (template_name, language, components) are not accepted by POST /v1/messages.
Send a message
POST /v1/messages
Required scope: write:messages · Rate limit: 60 req/min
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | ✅ | whatsapp, instagram, or messenger |
to | string | ✅ | Recipient phone number in E.164 format, or channel-specific ID |
type | string | ✅ | text, image, video, document, or audio |
text | string | If type=text | The message body |
media_url | string | If media type | Publicly accessible URL of the file |
caption | string | ❌ | Caption for image or video messages |
filename | string | ❌ | Filename displayed for document messages |
idempotency_key | string | ❌ | Unique key to prevent duplicate sends on retry |
Send a text message
curl -X POST https://api.safravo.com/v1/messages \
-H "X-API-Key: saf_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"to": "+254712345678",
"type": "text",
"text": "Hello! Your order has shipped. 📦"
}'const { data } = await client.post('/v1/messages', {
channel: 'whatsapp',
to: '+254712345678',
type: 'text',
text: 'Hello! Your order has shipped. 📦',
idempotency_key: `msg_${Date.now()}`,
});
console.log(data.message_id);resp = client.post("/v1/messages", json={
"channel": "whatsapp",
"to": "+254712345678",
"type": "text",
"text": "Hello! Your order has shipped. 📦",
})
resp.raise_for_status()
print(resp.json()["message_id"])$response = Http::withHeaders(['X-API-Key' => env('SAFRAVO_API_KEY')])
->post('https://api.safravo.com/v1/messages', [
'channel' => 'whatsapp',
'to' => '+254712345678',
'type' => 'text',
'text' => 'Hello! Your order has shipped. 📦',
]);
$messageId = $response->json('message_id');payload, _ := json.Marshal(map[string]string{
"channel": "whatsapp",
"to": "+254712345678",
"type": "text",
"text": "Hello! Your order has shipped.",
})
req, _ := http.NewRequest("POST", "https://api.safravo.com/v1/messages",
bytes.NewBuffer(payload))
req.Header.Set("X-API-Key", os.Getenv("SAFRAVO_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()uri = URI('https://api.safravo.com/v1/messages')
req = Net::HTTP::Post.new(uri)
req['X-API-Key'] = ENV['SAFRAVO_API_KEY']
req['Content-Type'] = 'application/json'
req.body = JSON.generate(
channel: 'whatsapp',
to: '+254712345678',
type: 'text',
text: 'Hello! Your order has shipped. 📦'
)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.request(req)
puts JSON.parse(response.body)['message_id']Send a document
curl -X POST https://api.safravo.com/v1/messages \
-H "X-API-Key: saf_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"to": "+254712345678",
"type": "document",
"media_url": "https://example.com/invoice_march.pdf",
"filename": "invoice_march_2025.pdf",
"caption": "Your invoice for March 2025"
}'await client.post('/v1/messages', {
channel: 'whatsapp',
to: '+254712345678',
type: 'document',
media_url: 'https://example.com/invoice_march.pdf',
filename: 'invoice_march_2025.pdf',
caption: 'Your invoice for March 2025',
});client.post("/v1/messages", json={
"channel": "whatsapp",
"to": "+254712345678",
"type": "document",
"media_url": "https://example.com/invoice_march.pdf",
"filename": "invoice_march_2025.pdf",
"caption": "Your invoice for March 2025",
}).raise_for_status()Http::withHeaders(['X-API-Key' => env('SAFRAVO_API_KEY')])
->post('https://api.safravo.com/v1/messages', [
'channel' => 'whatsapp',
'to' => '+254712345678',
'type' => 'document',
'media_url' => 'https://example.com/invoice_march.pdf',
'filename' => 'invoice_march_2025.pdf',
'caption' => 'Your invoice for March 2025',
]);Response
{
"message_id": "6634e2b1a7c3f500231a8b01",
"conversation_id": "6634e2b1a7c3f500231a8b00",
"status": "queued",
"channel": "whatsapp",
"to": "+254712345678"
}Use message_id to poll delivery status via GET /v1/messages/:id.
Send a template message
POST /v1/messages/templates
Required scopes: write:messages, read:templates · Rate limit: 20 req/min
Templates must be approved by Meta before use. Fetch your approved
templates and their variable structure via GET /v1/templates. Each template
response includes a ready-to-copy send_example payload.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | ✅ | Recipient phone number in E.164 format |
template_name | string | ✅ | Exact template name as returned by GET /v1/templates |
language | string | ✅ | BCP-47 language code, e.g. en or sw. Defaults to en |
components | array | If template has variables | Variable substitutions (see below) |
idempotency_key | string | ❌ | Prevents duplicate sends on retry |
Filling template variables
curl -X POST https://api.safravo.com/v1/messages/templates \
-H "X-API-Key: saf_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+254712345678",
"template_name": "order_confirmed_001",
"language": "en",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Jane Doe" },
{ "type": "text", "text": "ORD-98765" }
]
}
]
}'await client.post('/v1/messages/templates', {
to: '+254712345678',
template_name: 'order_confirmed_001',
language: 'en',
components: [
{
type: 'body',
parameters: [
{ type: 'text', text: 'Jane Doe' },
{ type: 'text', text: 'ORD-98765' },
],
},
],
idempotency_key: `tpl_${Date.now()}`,
});client.post("/v1/messages/templates", json={
"to": "+254712345678",
"template_name": "order_confirmed_001",
"language": "en",
"components": [
{
"type": "body",
"parameters": [
{"type": "text", "text": "Jane Doe"},
{"type": "text", "text": "ORD-98765"},
],
}
],
}).raise_for_status()Http::withHeaders(['X-API-Key' => env('SAFRAVO_API_KEY')])
->post('https://api.safravo.com/v1/messages/templates', [
'to' => '+254712345678',
'template_name' => 'order_confirmed_001',
'language' => 'en',
'components' => [[
'type' => 'body',
'parameters' => [
['type' => 'text', 'text' => 'Jane Doe'],
['type' => 'text', 'text' => 'ORD-98765'],
],
]],
]);type Parameter struct {
Type string `json:"type"`
Text string `json:"text"`
}
type Component struct {
Type string `json:"type"`
Parameters []Parameter `json:"parameters"`
}
type TemplateRequest struct {
To string `json:"to"`
TemplateName string `json:"template_name"`
Language string `json:"language"`
Components []Component `json:"components"`
}
body, _ := json.Marshal(TemplateRequest{
To: "+254712345678",
TemplateName: "order_confirmed_001",
Language: "en",
Components: []Component{{
Type: "body",
Parameters: []Parameter{
{Type: "text", Text: "Jane Doe"},
{Type: "text", Text: "ORD-98765"},
},
}},
})
req, _ := http.NewRequest("POST",
"https://api.safravo.com/v1/messages/templates",
bytes.NewBuffer(body))
req.Header.Set("X-API-Key", os.Getenv("SAFRAVO_API_KEY"))
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)Template with a media header
If the template has an IMAGE, VIDEO, or DOCUMENT header, include a header component with the media URL:
{
"to": "+254712345678",
"template_name": "safravo_pos_receipt",
"language": "en",
"components": [
{
"type": "header",
"parameters": [
{ "type": "document", "link": "https://example.com/receipt.pdf" }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Ksh 1,200" },
{ "type": "text", "text": "Jasper's Pharmacy" },
{ "type": "text", "text": "receipt" }
]
}
]
}Get message status
GET /v1/messages/:id
Required scope: read:messages
curl https://api.safravo.com/v1/messages/6634e2b1a7c3f500231a8b01 \
-H "X-API-Key: saf_live_your_key"const { data } = await client.get(`/v1/messages/${messageId}`);
console.log(data.status); // "delivered"resp = client.get(f"/v1/messages/{message_id}")
print(resp.json()["status"]) # "delivered"$status = Http::withHeaders(['X-API-Key' => env('SAFRAVO_API_KEY')])
->get("https://api.safravo.com/v1/messages/{$messageId}")
->json('status');Response
{
"message_id": "6634e2b1a7c3f500231a8b01",
"conversation_id": "6634e2b1a7c3f500231a8b00",
"channel": "whatsapp",
"direction": "outbound",
"status": "failed",
"failure_reason": "[132001] WhatsApp API Error: (#132001) Template name does not exist in the translation",
"type": "text",
"created_at": "2025-05-01T12:00:00.000Z",
"updated_at": "2025-05-01T12:00:05.000Z"
}Message statuses
| Status | Description |
|---|---|
queued | Accepted and waiting for dispatch |
sent | Delivered to the channel provider |
delivered | Confirmed delivery to the recipient's device |
read | Recipient opened the message |
failed | Delivery failed — check failure_reason for details |
A queued response means Safravo accepted your request — it does not mean
Meta has delivered the message. If a message fails asynchronously, the
status updates to failed and a failure_reason field is appended with the
exact error from the channel provider.
For real-time delivery updates instead of polling, subscribe to
message.status_updated webhook events. See Webhooks.