Safravo Logosafravo.com

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 POST requests with Content-Type: application/json
  • Respond with any 2xx status code within 30 seconds
  • Be accessible from the public internet — localhost URLs 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
HeaderDescription
X-Safravo-SignatureHMAC-SHA256 of the raw request body, prefixed with sha256=
X-Safravo-DeliveryUnique ID for this delivery attempt — use for deduplication
X-Safravo-EventThe 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
end

Event types

EventTrigger
message.createdA new inbound or outbound message is created
message.status_updatedA message delivery status changes (sent → delivered → read)
contact.createdA new contact is created in the workspace
contact.updatedA contact's details are updated
conversation.createdA new conversation is started
conversation.assignedA conversation is assigned to an agent
conversation.resolvedA conversation is marked as resolved
broadcast.completedA 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:

AttemptDelay
1 (initial)Immediate
25 seconds
330 seconds
42 minutes
510 minutes
6–101 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.

StatusDescription
activeReceiving events normally
failing5+ consecutive failures — investigate immediately
disabledAuto-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: 24h

Testing 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:3000

Register 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.

On this page