Skip to main content

Webhook integration

Relavoi pushes session and call events to your webhook URL within seconds of the underlying telephony event. This guide covers registration, event types, signature verification in three languages, and the retry contract.

1. Register your endpoint​

Each tenant has a single webhook URL. Registering again replaces the previous URL and rotates the signing secret. Register via the API:

curl -X POST https://api.relavoi.com/v1/webhooks \
-H "Authorization: Bearer $RELAVOI_JWT" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourcompany.com/relavoi/webhooks",
"events": [
"session.created",
"session.activated",
"session.expired",
"call.incoming",
"call.answered",
"call.ended",
"call.failed",
"sms.sent",
"sms.received"
]
}'

events is the list of event names you want delivered (must be non-empty). The response returns the endpoint and a freshly generated signing secret:

{
"url": "https://api.yourcompany.com/relavoi/webhooks",
"secret": "whsec_YOUR_WEBHOOK_SECRET",
"events": ["session.created", "call.answered"]
}

Store the secret (prefixed whsec_) somewhere safe — you need it for signature verification, and it is only returned at registration time. GET /v1/webhooks returns your current url, subscribed events, hasSecret, and recent delivery attempts, but never the secret itself.

2. Event types​

EventWhen it fires
session.createdSession inserted in PENDING
session.activatedFirst call bridges, state becomes ACTIVE
session.expiredState transitions to EXPIRED (either via grace expiry or hard timeout)
call.incomingInbound call arrives on the proxy, before bridge
call.answeredCustomer or agent picks up
call.endedEither party hangs up
call.failedCall routing failed (busy, no answer, network)
sms.receivedInbound SMS arrives on the proxy
sms.sentOutbound SMS leaves on the proxy

Every delivered request body is a JSON envelope with three fields:

{
"event": "call.answered",
"timestamp": "2026-07-15T12:15:35.673Z",
"data": { }
}
  • event — the event name (same values as the table above)
  • timestamp — ISO 8601 UTC time the delivery was generated
  • data — an event-specific object

3. Signature headers​

Every request carries these headers:

X-Relavoi-Event: call.answered
X-Relavoi-Delivery: 0f8d9c1a-3b2e-4c7d-9a1f-6e2b8c4d5a90
X-Relavoi-Timestamp: 1747920662
X-Relavoi-Signature: sha256=8e2d3a...c4f9

The signing string is the timestamp header value, a literal ., and the raw request body:

${timestamp}.${rawBody}

The signature is the hex HMAC-SHA256 of the signing string using your webhook secret as the key, sent as X-Relavoi-Signature: sha256=<hex>. The X-Relavoi-Delivery header is a stable delivery id (unchanged across retries of the same event) — use it as your idempotency key.

:::warning Reject stale timestamps Always reject requests where |now - timestamp| > 300 seconds. This prevents replay attacks if a signed payload leaks. :::

4. Verification examples​

Node.js​

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.RELAVOI_WEBHOOK_SECRET!;

app.post(
'/relavoi/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const sigHeader = req.header('X-Relavoi-Signature') ?? '';
const ts = req.header('X-Relavoi-Timestamp') ?? '';
const sig = sigHeader.startsWith('sha256=') ? sigHeader.slice(7) : '';

if (!sig || !ts) return res.status(400).end();
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).end();

const expected = crypto
.createHmac('sha256', SECRET)
.update(`${ts}.${req.body.toString('utf8')}`)
.digest('hex');

const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.status(401).end();

const event = JSON.parse(req.body.toString('utf8'));
// handle event.type ...
res.status(200).end();
},
);

Python​

import hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RELAVOI_WEBHOOK_SECRET"].encode()

@app.post("/relavoi/webhooks")
def handle():
ts = request.headers.get("X-Relavoi-Timestamp", "")
sig_header = request.headers.get("X-Relavoi-Signature", "")
sig = sig_header[7:] if sig_header.startswith("sha256=") else ""

if not ts or not sig:
abort(400)
if abs(time.time() - int(ts)) > 300:
abort(400)

signing = f"{ts}.{request.get_data(as_text=True)}".encode()
expected = hmac.new(SECRET, signing, hashlib.sha256).hexdigest()

if not hmac.compare_digest(expected, sig):
abort(401)

event = request.get_json()
# handle event["type"] ...
return "", 200

Go​

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)

var secret = []byte(os.Getenv("RELAVOI_WEBHOOK_SECRET"))

func handle(w http.ResponseWriter, r *http.Request) {
ts := r.Header.Get("X-Relavoi-Timestamp")
sig := r.Header.Get("X-Relavoi-Signature")

var v1 string
if strings.HasPrefix(sig, "sha256=") {
v1 = strings.TrimPrefix(sig, "sha256=")
}

tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || v1 == "" {
http.Error(w, "bad headers", http.StatusBadRequest)
return
}
if abs(time.Now().Unix()-tsInt) > 300 {
http.Error(w, "stale", http.StatusBadRequest)
return
}

body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))

if !hmac.Equal([]byte(expected), []byte(v1)) {
http.Error(w, "bad sig", http.StatusUnauthorized)
return
}
// handle event ...
w.WriteHeader(http.StatusOK)
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }

5. Retry policy​

If your endpoint returns anything other than 2xx within 5 seconds, Relavoi retries with exponential backoff:

AttemptDelay after previous
1(initial)
230 seconds
32 minutes
410 minutes

After the fourth attempt the event goes to the dead-letter queue, visible at GET /v1/webhooks/logs. You can replay any DLQ event from the dashboard.

:::tip Idempotency Always treat webhook delivery as at-least-once. Use the X-Relavoi-Delivery header as your idempotency key — it is stable across retries of the same event. :::