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.

Permanent endpoint

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.

Per-request URL

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 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, regardless of the service. A new service adds new type values without changing the structure.

FieldTypeMeaning
iduuidIdentifier of the logical event.
typestringDotted type, for example sms.delivered, voice.answered or numberinsight.batch.completed.
versionnumberEnvelope version. Today 1; an incompatible change bumps 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.

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:

  1. Read the raw body, without re-serializing the JSON: a whitespace change invalidates the signature.
  2. Read X-Hablame-Timestamp and reject anything outside your tolerance window. 5 minutes is reasonable.
  3. Compute hex(HMAC-SHA256(secret, timestamp + "." + body)).
  4. Compare against the part after sha256= using a constant-time comparison: hash_equals in PHP, hmac.compare_digest in Python.
  5. 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 timestamp is 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 http and https are accepted.
  • Answer fast. If your handler is slow, queue internally and return 2xx right away: otherwise the delivery times out and enters retries.