Webhooks
When something interesting happens with your services, Hablame notifies you with a POST to a URL you control. This page documents the contract: the event envelope, the signature headers, how to verify them and what to do about retries.
7 min read
How an endpoint is registered
Endpoint management (registering, removing, rotating the secret and delivery history) happens in the customer portal, not through the public API: the system only sends you webhooks, you do not need to call anything to configure them. There are two modes.
Registered once from the portal, choosing the service and the event types you want to listen to (or all of them, by default). Each endpoint carries its own secret, used to sign all of its deliveries.
Some API endpoints accept a field with an ad-hoc URL, for example webhookUrl in the Number Insight batch. That URL receives the event signed with the account 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 confirm receipt; anything else (4xx, 5xx or a timeout) counts as a failure and enters the retry cycle.
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, regardless of the service. A new service adds new type values without changing the structure.
| Field | Type | Meaning |
|---|---|---|
id | uuid | Identifier of the logical event. |
type | string | Dotted type, for example sms.delivered, voice.answered or numberinsight.batch.completed. |
version | number | Envelope version. Today 1; an incompatible change bumps 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. |
Headers on every delivery
X-Hablame-Event-Type- The event
type: useful to route before parsing the JSON. X-Hablame-Delivery-Id- Unique identifier of this delivery. It differs between retries of the same event.
X-Hablame-Timestamp- Signing moment, in epoch seconds. Used to detect replays.
X-Hablame-Signature- HMAC signature of the body, in
sha256=<hex>format. Idempotency-Key- Equal to the delivery identifier. Use it to deduplicate on your side across retries of the same event.
Content-Type- Always
application/json.
Verifying the HMAC signature
We compute the signature with 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, without re-serializing the JSON: a whitespace change invalidates the signature.
- Read
X-Hablame-Timestampand reject anything outside your tolerance window. 5 minutes is reasonable. - Compute
hex(HMAC-SHA256(secret, timestamp + "." + body)). - Compare against the part after
sha256=using a constant-time comparison:hash_equalsin PHP,hmac.compare_digestin Python. - If a rotation is under way, 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-reenvio: 5 minutos.
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));
}Idempotency: what to assume
Delivery is at least once: on an intermediate failure (the network, your server or ours) we resend the same event with the same delivery identifier. Any serious integration must deduplicate on that header: store it in a table with a uniqueness constraint and discard the second insert.
If your handler does something non-idempotent (a charge, a chat message), the deduplication has to wrap that. Answering 200 and processing later is not enough: if your service goes down between the 200 and the commit, you lose the event.
Retries and backoff
If a delivery fails, we requeue it with exponential backoff and jitter (roughly 5 s, 10 s, 20 s, capped at 1 hour) for about 12 attempts. After that the delivery is marked as failed and moves to a review queue we monitor. Retries keep their priority: urgent events go through a dedicated lane for near-immediate delivery.
After several consecutive failures on the same endpoint we disable it automatically, so we stop sending to a dead URL. A later successful delivery re-enables it.
Security
- Always verify the signature. Without verification, anyone can POST to your URL pretending to be Hablame.
- Validate the timestamp. A replayed old signature is still valid, but its
timestampis old. A ±5 minute window removes the reuse. - Read the secret from the environment, not from the repository. Rotate it periodically from the portal. During rotation we sign with both the new and the previous secret; your verifier can try both.
- HTTPS required. We validate the URL before delivering: loopback addresses and private ranges are blocked, and only real
httpandhttpsare accepted. - Answer fast. If your handler is slow, queue internally and return 2xx right away: otherwise the delivery times out and enters retries.