Safravo Logosafravo.com

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

FieldTypeRequiredDescription
channelstringwhatsapp, instagram, or messenger
tostringRecipient phone number in E.164 format, or channel-specific ID
typestringtext, image, video, document, or audio
textstringIf type=textThe message body
media_urlstringIf media typePublicly accessible URL of the file
captionstringCaption for image or video messages
filenamestringFilename displayed for document messages
idempotency_keystringUnique 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

FieldTypeRequiredDescription
tostringRecipient phone number in E.164 format
template_namestringExact template name as returned by GET /v1/templates
languagestringBCP-47 language code, e.g. en or sw. Defaults to en
componentsarrayIf template has variablesVariable substitutions (see below)
idempotency_keystringPrevents 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

StatusDescription
queuedAccepted and waiting for dispatch
sentDelivered to the channel provider
deliveredConfirmed delivery to the recipient's device
readRecipient opened the message
failedDelivery 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.

On this page