Real-time events and signature verification

Open in ClaudeOpen in ChatGPT.md

Webhooks

Get real-time updates instead of polling. Each event is POSTed as JSON to your HTTPS URL.

Setup

bash
curl -X POST https://smsprovider-web.vercel.app/api/v1/webhooks \
  -H "Authorization: Bearer $BATLER_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/batler", "events": ["message.delivered", "message.failed", "inbound.received"]}'

The response includes a secret (whsec_…) — shown only once. Store it to verify signatures.

Events

EventWhen
message.sentHanded to the mobile network
message.deliveredDelivery receipt from the handset
message.failedFailure, with reason (e.g. blocked, no_coverage, kosher_phone)
message.expiredNo delivery receipt in time
inbound.receivedA reply to your sender number
otp.verified / otp.failedOTP verification outcome
wallet.low_balance / wallet.creditedLow balance / credits purchased
blacklist.addedA recipient opted out
link.clickedA tracked link was clicked

Shape:

json
{
  "id": "4f0c7c1e-1c2b-4a0e-9d7b-2e3f4a5b6c7d",
  "type": "message.delivered",
  "created_at": "2026-11-02T18:00:05Z",
  "data": { "message_id": "0e9b2f0a-6c1d-4f43-8c33-6f3b3f0d9a11", "to": "+972501234567", "ref": "order-123", "status": "delivered", "reason": null, "operator": "Partner", "occurred_at": "2026-11-02T18:00:04Z" }
}

Verifying the signature

Every request carries X-Batler-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>"), plus X-Batler-Event-Id for deduplication. Compute the signature over the raw body and reject timestamps older than 5 minutes.

Node.js

ts
import { verifyWebhookSignature } from "@batler/sms";

app.post("/hooks/batler", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  if (!(await verifyWebhookSignature(process.env.BATLER_WEBHOOK_SECRET!, req.get("X-Batler-Signature"), raw))) return res.sendStatus(401);
  const event = JSON.parse(raw);
  // dedupe by req.get("X-Batler-Event-Id"), then handle event.type
  res.sendStatus(200);
});

PHP

php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BATLER_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $p);
$expected = hash_hmac('sha256', $p['t'] . '.' . $raw, getenv('BATLER_WEBHOOK_SECRET'));
if (!isset($p['v1']) || !hash_equals($expected, $p['v1']) || abs(time() - (int)$p['t']) > 300) {
    http_response_code(401); exit;
}
$event = json_decode($raw, true);
http_response_code(200);

Python

python
import hmac, hashlib, time

def verify(secret: str, header: str, raw: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Retries

Return 2xx within 10 seconds. Otherwise we retry after 1m, 5m, 30m, 2h, 6h and 24h. After 10 consecutive failures the endpoint is disabled and you get an email. Delivery log and replay: GET https://smsprovider-web.vercel.app/api/v1/webhooks/{id}/deliveries and …/replay.

☰