Webhooks
Receive real-time push events from Safravo — no polling required.
Webhooks let your application receive real-time notifications when events occur in your Safravo workspace. When an event fires, Safravo sends an HTTP POST to your registered endpoint URL with a signed JSON payload.
Setting up a webhook
Add an endpoint
Go to Settings → Webhooks in the dashboard and click Add Endpoint.
Configure the endpoint
Enter your public HTTPS endpoint URL, give it an optional description, and select the events you want to subscribe to.
Save your signing secret
After clicking Add Endpoint, the signing secret (whsec_<64 hex chars>) is shown once. Copy it immediately and store it in your secrets manager (AWS Secrets Manager, Doppler, Vercel env vars). If lost, delete the endpoint and create a new one.
Verify signatures in your server
Every delivery includes an X-Safravo-Signature header. Always verify it before processing any event. See Verifying Signatures below.
The webhook signing secret is shown once at creation. Store it immediately. If lost, delete the endpoint and create a new one.
Endpoint requirements
Your server must:
- Accept
POSTrequests withContent-Type: application/json - Respond with any
2xxstatus code within 30 seconds - Be accessible from the public internet —
localhostURLs will not work
Request headers
Every delivery includes these headers:
X-Safravo-Signature: sha256=<hex_digest>
X-Safravo-Delivery: evt_01HTXYZ123
X-Safravo-Event: message.created
Content-Type: application/json| Header | Description |
|---|---|
X-Safravo-Signature | HMAC-SHA256 of the raw request body, prefixed with sha256= |
X-Safravo-Delivery | Unique ID for this delivery attempt — use for deduplication |
X-Safravo-Event | The event type that triggered this delivery |
Verifying signatures
Always verify the signature before processing any event. This prevents replay attacks and confirms the payload came from Safravo.
Use the raw request body (before any JSON parsing) when computing the HMAC. Parsing and re-serialising changes whitespace and breaks signature validation.
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean {
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
app.post(
'/webhooks/safravo',
express.raw({ type: 'application/json' }), // ← raw body required
(req, res) => {
const sig = req.headers['x-safravo-signature'] as string;
if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
console.log('Event:', event.event, event.id);
res.status(200).json({ received: true });
},
);import hashlib
import hmac
import os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
def verify_signature(raw_body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/safravo")
async def handle_webhook(
request: Request,
x_safravo_signature: str = Header(...),
x_safravo_event: str = Header(...),
):
raw_body = await request.body()
if not verify_signature(raw_body, x_safravo_signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
print(f"Event: {x_safravo_event} | ID: {event['id']}")
return {"received": True}// Laravel — add this route to routes/api.php
// Exclude from CSRF in VerifyCsrfToken middleware
Route::post('/webhooks/safravo', function (Request $request) {
$rawBody = $request->getContent();
$signature = $request->header('X-Safravo-Signature');
$secret = env('WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
abort(401, 'Invalid signature');
}
$event = $request->json()->all();
Log::info('Safravo webhook', ['event' => $event['event'], 'id' => $event['id']]);
return response()->json(['received' => true]);
});package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func verifySignature(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Safravo-Signature")
if !verifySignature(body, sig, os.Getenv("WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]interface{}
json.Unmarshal(body, &event)
fmt.Printf("Event: %s | ID: %s\n", event["event"], event["id"])
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received":true}`))
}
func main() {
http.HandleFunc("/webhooks/safravo", webhookHandler)
http.ListenAndServe(":8080", nil)
}require 'sinatra'
require 'openssl'
require 'json'
post '/webhooks/safravo' do
raw_body = request.body.read
signature = request.env['HTTP_X_SAFRAVO_SIGNATURE']
secret = ENV['WEBHOOK_SECRET']
expected = 'sha256=' + OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)
unless Rack::Utils.secure_compare(expected, signature)
halt 401, 'Invalid signature'
end
event = JSON.parse(raw_body)
puts "Event: #{event['event']} | ID: #{event['id']}"
content_type :json
{ received: true }.to_json
endEvent types
| Event | Trigger |
|---|---|
message.created | A new inbound or outbound message is created |
message.status_updated | A message delivery status changes (sent → delivered → read) |
contact.created | A new contact is created in the workspace |
contact.updated | A contact's details are updated |
conversation.created | A new conversation is started |
conversation.assigned | A conversation is assigned to an agent |
conversation.resolved | A conversation is marked as resolved |
broadcast.completed | A broadcast campaign finishes sending |
Payload structure
All events share the same envelope:
{
"id": "evt_01HTXYZ123",
"event": "message.created",
"created_at": "2025-05-01T12:00:00.000Z",
"workspace_id": "6634e2b1a7c3f500231a8b00",
"data": {}
}message.created
{
"id": "evt_01HTXYZ123",
"event": "message.created",
"created_at": "2025-05-01T12:00:00.000Z",
"workspace_id": "6634e2b1a7c3f500231a8b00",
"data": {
"message_id": "6634e2b1a7c3f500231a8b01",
"conversation_id": "6634e2b1a7c3f500231a8b02",
"channel": "whatsapp",
"direction": "inbound",
"type": "text",
"text": "Hello, I need help with my order.",
"from": "+254712345678",
"contact": {
"id": "6634e2b1a7c3f500231a8b03",
"name": "Jane Doe"
}
}
}message.status_updated
{
"id": "evt_01HTXYZ124",
"event": "message.status_updated",
"created_at": "2025-05-01T12:00:05.000Z",
"workspace_id": "6634e2b1a7c3f500231a8b00",
"data": {
"message_id": "6634e2b1a7c3f500231a8b01",
"status": "failed",
"failure_reason": "[132001] WhatsApp API Error: (#132001) Template name does not exist in the translation"
}
}Delivery and retries
Safravo retries failed deliveries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 (initial) | Immediate |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
| 6–10 | 1 hour each |
After 10 failed attempts, that specific event delivery is marked permanently failed.
Circuit breaker
After 25 consecutive delivery failures, your endpoint is automatically disabled. Safravo sends an email notification to the workspace owner when this happens.
| Status | Description |
|---|---|
active | Receiving events normally |
failing | 5+ consecutive failures — investigate immediately |
disabled | Auto-disabled after 25 failures — manual re-enable required |
The failure counter resets to zero on any successful delivery.
To re-enable: go to Settings → Webhooks, find the endpoint, click Re-enable. The first retry is delayed 5 minutes as a cooldown.
Deduplication
Use X-Safravo-Delivery as an idempotency key. Store processed delivery IDs for at least 24 hours to handle safe retries:
const deliveryId = req.headers['x-safravo-delivery'];
const alreadyProcessed = await redis.get(`webhook:${deliveryId}`);
if (alreadyProcessed) return res.status(200).json({ received: true });
// ... process event ...
await redis.setex(`webhook:${deliveryId}`, 86400, '1'); // TTL: 24hTesting locally
Use ngrok or Cloudflare Tunnel to expose your local server:
# ngrok
ngrok http 3000
# → https://abc123.ngrok-free.app/webhooks/safravo
# Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Register the tunnel URL in Settings → Webhooks. Update it when the tunnel restarts (ngrok free tier regenerates URLs on restart — use a paid account or Cloudflare Tunnel for a stable URL).
See Webhook Debugging for troubleshooting delivery failures.