๐Ÿ“–docs

Everything you need to go from invite code to verified delivery.


$ quickstart

Sign up with your beta code. The response contains your API key and HMAC signing secret โ€” both are shown exactly once, so store them in a secrets manager before you close the terminal.

curl -X POST https://api.heraldq.com/v1/signup \
  -H 'content-type: application/json' \
  -d '{"name":"you","email":"you@example.com","invite_code":"BETA-XXXX"}'
# -> { "tenant_id": "...", "api_key": "sliq_...",
#      "hmac_secret": "...", "expires_at": "..." }

Queue a webhook. You get a 202 and a ULID back immediately.

curl -X POST https://api.heraldq.com/v1/webhook \
  -H 'authorization: Bearer sliq_...' \
  -H 'content-type: application/json' \
  -d '{"destination":"https://your-app.com/hook","payload":{"hello":"world"}}'
# -> 202  { "id": "01J..." }

Check on it any time:

curl -H 'authorization: Bearer sliq_...' https://api.heraldq.com/v1/webhook/01J...
# -> { "status": "delivered" }                      # done
# -> { "status": "pending", "attempts": 3,
#      "last_error": "...", "next_attempt_at": "..." }  # still retrying
# -> { "status": "failed", ... }                    # dead-lettered

$ retries & failure handling

Every delivery attempt is judged by your endpoint's response:

your responsewhat we do
2xxsuccess โ€” recorded, visible in GET /v1/deliveries
408, 425, 429, 5xxretry with backoff
timeout (20s) or connection failureretry with backoff
anything else (3xx, other 4xxpermanent failure โ€” dead-lettered immediately

Retries back off exponentially, 15 attempts over 34 hours. After the last failure the webhook lands in the DLQ.

$ dead-letter queue & replay

Permanently failed webhooks aren't lost โ€” they're parked with the failure reason and last status code:

curl -H 'authorization: Bearer sliq_...' https://api.heraldq.com/v1/dlq

Fixed your receiver? Replay any dead-lettered webhook with one call. Replay is idempotent โ€” firing it twice won't double-deliver:

curl -X POST -H 'authorization: Bearer sliq_...' \
  https://api.heraldq.com/v1/dlq/01J.../replay

$ verifying signatures

Every delivery to your endpoint carries two headers:

HeraldQ-Signature: t=1720000000,v1=5257a869e7ecebeda32affa62cdca3f...
Idempotency-Key: 01J1XAMPLE0ULID0000000000

t is the Unix timestamp of the attempt; v1 is hex-encoded HMAC-SHA256(secret, "{t}." + raw_body). Verify against the raw body bytes โ€” don't parse and re-serialize the JSON, or key ordering will break the signature. Reject anything where t is more than 5 minutes from your clock, and compare with a constant-time function, never ==.

Python:

import hashlib, hmac, time

def verify(secret: str, sig_header: str, raw_body: bytes, tolerance_secs: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    timestamp, expected = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance_secs:
        return False

    signed_content = timestamp.encode() + b"." + raw_body
    computed = hmac.new(secret.encode(), signed_content, hashlib.sha256).hexdigest()
    return hmac.compare_digest(computed, expected)

Node:

const crypto = require("node:crypto");

function verify(secret, sigHeader, rawBody, toleranceSecs = 300) {
  const parts = Object.fromEntries(sigHeader.split(",").map((p) => p.split("=", 2)));
  const { t, v1 } = parts;

  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSecs) return false;

  const computed = crypto
    .createHmac("sha256", secret)
    .update(`${t}.`)
    .update(rawBody) // the raw Buffer, not a re-serialized object
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(v1));
}

Each retry is signed fresh with a new t, so the replay-window check won't reject legitimate retries.

$ at-least-once & idempotency

heraldQ guarantees at-least-once delivery, not exactly-once. In rare cases a webhook you already processed is delivered again. Every attempt of the same webhook carries the same Idempotency-Key (its ULID) โ€” dedupe on it: already seen the key? Return 200 and skip your side effects. And respond fast: return 2xx as soon as the webhook is durably accepted, then do slow processing after โ€” we time out at 20 seconds.

$ limits & destination rules

limitvaluewhen exceeded
enqueue rate10 req/s per account429, retry shortly
payload size2 MB per webhook413
delivery timeout20s per attempt (5s connect)counts as retryable failure

Destinations must be HTTPS, publicly routable (private / loopback / cloud-metadata addresses are blocked), and free of credentials in the URL โ€” authenticate by verifying the signature instead.


Stuck? Email dylan@heraldq.com with the x-request-id from the response and we'll dig in.