Webhooks

Whenever something interesting happens with your services (an SMS got delivered, an email was opened, a call was answered, a Number Insight batch finished), Hablame notifies you with a POST to a URL you control. This page documents the contract: the event envelope, signature headers, how to verify them, and what to do about retries.

Registering an endpoint

Endpoint management (registering, removing, rotating secrets, delivery history) happens in the cloud portal, not the public API: the system only sends webhooks, so you don't need to call anything to configure them. Two modes:

  1. Permanent endpoint: registered once in the portal, choosing the service (sms, email, voice, etc.) and the event_types you want to listen for (or all by default). Each endpoint has its own secret used to sign all of its deliveries.
  2. Per-request URL: some API endpoints accept a field (e.g. webhookUrl in the Number Insight batch) with an ad-hoc URL. That URL receives the event signed with the account-level secret (shared by all per-request URLs).

HTTP contract

Each delivery is a POST with a JSON body. Return any 2xx within a few seconds to acknowledge receipt; anything else (non-2xx 4xx, 5xx, timeout) counts as failure and enters the retry pipeline.

POST to your endpoint
POST /webhooks/hablame HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Hablame-Webhooks/1
X-Hablame-Event-Type: sms.delivered
X-Hablame-Delivery-Id: b85b3d50-7c44-4ed8-aac6-3c2b6a4fe1aa
X-Hablame-Timestamp:   1781832862
X-Hablame-Signature:   sha256=8c4f3c2b7a91…
Idempotency-Key:       b85b3d50-7c44-4ed8-aac6-3c2b6a4fe1aa

{ "id": "…", "type": "sms.delivered", "version": 1,  }

The event envelope

All events share the same shape, service-agnostic. A new service adds new type values without changing the structure.

FieldTypeMeaning
iduuidLogical event identifier.
typestringDotted type, e.g. sms.delivered, voice.answered, numberinsight.batch.completed.
versionnumberEnvelope version. Today 1; future incompatible changes bump this number.
servicestringEmitting service (sms, email, voice, numberinsight, …).
accountIdnumberAccount the event belongs to.
occurredAtISO-8601When the event happened (with timezone).
dataobjectPayload specific to the type.

Delivery headers

HeaderMeaning
X-Hablame-Event-TypeThe event type, handy for routing before parsing JSON.
X-Hablame-Delivery-IdUnique identifier of this delivery (UUID). Different across retries of the same event.
X-Hablame-TimestampEpoch in seconds when the signature was generated, used for replay detection.
X-Hablame-SignatureHMAC of the body. Format sha256=<hex>.
Idempotency-KeyEqual to the delivery_id. Lets you deduplicate retries of the same event.
Content-TypeAlways application/json.

Verifying the HMAC signature

The signature is HMAC-SHA256 over the string "{timestamp}.{raw_body}", using the endpoint secret (or the account secret for per-request URLs). The header carries sha256=<hex>.

To verify:

  1. Read the raw body (do not re-serialize JSON, since a whitespace change invalidates the signature).
  2. Read X-Hablame-Timestamp and reject if outside your tolerance window (5 minutes is reasonable).
  3. Compute hex(HMAC-SHA256(secret, timestamp + "." + body)).
  4. Compare with the part after sha256= using a constant-time comparison (PHP hash_equals, Python hmac.compare_digest).
  5. If a rotation is in progress, also accept the previous secret during the rotation window.
PHP
function verifyHablameSignature(string $rawBody, array $headers, string $secret): bool {
    $sig  = $headers['x-hablame-signature'] ?? '';
    $ts   = (int) ($headers['x-hablame-timestamp'] ?? 0);
    // Anti-replay: 5 minutes.
    if (\abs(\time() - $ts) > 300) { return false; }
    if (!\str_starts_with($sig, 'sha256=')) { return false; }
    $expected = \hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
    return \hash_equals($expected, \substr($sig, 7));
}
Python
import hmac, hashlib, time

def verify(raw_body: bytes, headers: dict, secret: bytes) -> bool:
    sig = headers.get('X-Hablame-Signature', '')
    ts  = int(headers.get('X-Hablame-Timestamp', 0))
    if abs(time.time() - ts) > 300:
        return False
    if not sig.startswith('sha256='):
        return False
    msg = f'{ts}.'.encode() + raw_body
    expected = hmac.new(secret, msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig[7:])

Idempotency: what to assume

Delivery is at-least-once: in the face of a mid-flight failure (the network, your server, our worker), we resend the same event with the same delivery_id/Idempotency-Key. Any serious integration must deduplicate by that header: store it in a table with a UNIQUE constraint, and drop the second insert.

If your handler does something non-idempotent (a charge, a chat message), the dedup must wrap that. Answering 200 and processing later is not enough: if your service dies between the 200 and the commit, you lose it.

Retries and backoff

If a delivery fails, we re-queue it with exponential backoff + jitter (~5s, 10s, 20s, …, capped at 1h) for up to about 12 attempts. After that, the delivery is marked failed and moved to a DLQ we monitor. Retries preserve priority: urgent events (voice.*) go through a dedicated queue for near-instant delivery.

After several consecutive failures against the same endpoint, we automatically disable it (circuit breaker) so we don't waste workers on a dead URL. A later success re-enables it.

Security

  • Always verify the signature. Without verification, anyone can POST to your URL pretending to be Hablame.
  • Validate the timestamp. An old valid signature replayed has a valid HMAC but an old timestamp. A ±5-minute window eliminates reuse.
  • Read the secret from the environment, not the repo. Rotate it periodically from the portal. During rotation we sign with both the new and previous secrets; your verifier can do the same (try both).
  • HTTPS only. We validate the URL before delivering (anti-SSRF: loopback and private ranges blocked, only real http/https).
  • Respond fast. If your handler is slow to process, queue internally and return 2xx immediately; otherwise the delivery times out and enters retries.