Callbacks
Set callback_url on a request and we will POST you every status change,
rather than making you poll.
The handshake
The first time a URL is used, we send it a challenge:
{ "challenge": "9f2c…" }
Echo the value back, unchanged, within three seconds:
{ "challenge": "9f2c…" }
Until that succeeds, no notifications are sent. This proves the URL is yours and is awake — it stops the API being turned into an amplifier for sending traffic at someone else.
Notifications
Each delivery carries the same body the query endpoint returns:
{
"task": {
"id": "424010985738629",
"status": "succeeded",
"content": { "url": "https://cdn.h3.studio/…" },
"usage": { "total_seconds": 5, "output_seconds": 5, "input_seconds": 0, "input_image_count": 0 }
}
}
Verifying the signature
Two headers come with every POST:
| Header | Meaning |
|---|---|
X-H3-Timestamp | Unix seconds when it was signed |
X-H3-Signature | sha256= + HMAC-SHA256 over "{timestamp}." + body |
import hmac, hashlib
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Compare with a constant-time function, not ==. Reject anything with a
timestamp more than a few minutes old, which is what stops a captured request
being replayed later.
Retries and duplicates
A non-2xx response or a timeout is retried with exponential backoff: 5 s, 10 s, 20 s, 40 s, 80 s, 160 s, then we stop.
Delivery is deduplicated on (task, status), so a task that reaches
succeeded once notifies once. Still, write your handler to be idempotent:
a network failure after your server committed but before we saw the 200 is
indistinguishable, from our side, from a failure before it.
Return 2xx as soon as you have durably recorded the event. Do the slow work afterwards — a handler that transcodes before responding will time out and get the same event again.