Everything you need to go from invite code to verified delivery.
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
Every delivery attempt is judged by your endpoint's response:
| your response | what we do |
|---|---|
2xx | success โ recorded, visible in GET /v1/deliveries |
408, 425, 429, 5xx | retry with backoff |
| timeout (20s) or connection failure | retry with backoff |
anything else (3xx, other 4xx |
Retries back off exponentially, 15 attempts over 34 hours. After the last failure the webhook lands in the DLQ.
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
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.
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.
| limit | value | when exceeded |
|---|---|---|
| enqueue rate | 10 req/s per account | 429, retry shortly |
| payload size | 2 MB per webhook | 413 |
| delivery timeout | 20s 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.