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:
-
Permanent endpoint: registered once in the
portal, choosing the service (sms, email, voice, etc.) and the
event_typesyou want to listen for (or all by default). Each endpoint has its own secret used to sign all of its deliveries. -
Per-request URL: some API endpoints accept a
field (e.g.
webhookUrlin 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 /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.
| Field | Type | Meaning |
|---|---|---|
id | uuid | Logical event identifier. |
type | string | Dotted type, e.g. sms.delivered, voice.answered, numberinsight.batch.completed. |
version | number | Envelope version. Today 1; future incompatible changes bump this number. |
service | string | Emitting service (sms, email, voice, numberinsight, …). |
accountId | number | Account the event belongs to. |
occurredAt | ISO-8601 | When the event happened (with timezone). |
data | object | Payload specific to the type. |
Delivery headers
| Header | Meaning |
|---|---|
X-Hablame-Event-Type | The event type, handy for routing before parsing JSON. |
X-Hablame-Delivery-Id | Unique identifier of this delivery (UUID). Different across retries of the same event. |
X-Hablame-Timestamp | Epoch in seconds when the signature was generated, used for replay detection. |
X-Hablame-Signature | HMAC of the body. Format sha256=<hex>. |
Idempotency-Key | Equal to the delivery_id. Lets you deduplicate retries of the same event. |
Content-Type | Always 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:
- Read the raw body (do not re-serialize JSON, since a whitespace change invalidates the signature).
- Read
X-Hablame-Timestampand reject if outside your tolerance window (5 minutes is reasonable). - Compute
hex(HMAC-SHA256(secret, timestamp + "." + body)). - Compare with the part after
sha256=using a constant-time comparison (PHPhash_equals, Pythonhmac.compare_digest). - If a rotation is in progress, also accept the previous secret during the rotation window.
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)); }
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.