Receive results with a webhook
- Updated
- Reading time
- 3 min
- Level
- intermediate
- Needs
- An HTTPS endpoint reachable from the internet
Supply callback_url and Revoye POSTs the completed job to it, so your code does not have to
hold a connection open for a minute. This is the right shape for anything unattended: batch work,
scheduled jobs, or a queue worker.
curl https://revoyeapi.degird.com/v1/completions \
-H "Authorization: Bearer $REVOYE_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Write release notes for this changelog: …",
"wait": false,
"callback_url": "https://your-app.example.com/hooks/revoye",
"metadata": { "release_id": "r_2291" }
}'You get a job id back immediately. Later, Revoye delivers the finished job to your endpoint.
The delivery
POST /hooks/revoye HTTP/1.1
X-Revoye-Signature: sha256=<hex>
X-Revoye-Timestamp: 1755561242
Content-Type: application/json
{ "id": "job_01JAY7Q2K8XYZ", "status": "succeeded", "response": "…", "metadata": { "release_id": "r_2291" } }The body is the same job object /v1/completions returns — including your
metadata, unchanged, which is how you route it without a database lookup.
Verify the signature
Verify before you parse. The URL is public; anything can POST to it.
Compute HMAC-SHA256 over the exact string "{timestamp}.{body}" using the signing secret shown when
you set the endpoint up, then compare in constant time.
import hmac, hashlib, time
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # reject replays older than 5 minutes
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, timestamp: string, rawBody: Buffer, signature: string) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"sha256=" +
createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}Three things people get wrong here, in order of frequency:
- Using the parsed body instead of the raw bytes. Re-serialising JSON changes whitespace and key order, and the signature will never match. Capture the raw body before any body parser touches it.
- Comparing with
==. A non-constant-time comparison leaks the signature one byte at a time. - Skipping the timestamp check. Without it, a captured delivery can be replayed indefinitely.
Delivery is at-least-once
Failed deliveries are retried with backoff. Your endpoint must be idempotent. At-least-once is what survives a network; exactly-once is not on offer from anyone who is being honest.
The practical shape: key your handler on the job id, record it, and make a second delivery of the
same id a no-op.
if already_processed(job["id"]):
return 200
process(job)
mark_processed(job["id"])
return 200Requirements for your endpoint
| Scheme | HTTPS only. An http:// URL is rejected at submission |
| Response | 2xx as fast as you can. Do the work asynchronously |
| Slow handler | Counts as a failure and earns a retry — so acknowledge first, work second |
| Failures | Retried with backoff |
Webhook or long poll?
| Use | When |
|---|---|
wait: true | Interactive work, a script you are watching, development. Simplest to write |
wait: false + callback_url | Everything unattended. Batches, schedules, queue workers, anything where an offline device should mean "later" and not "error" |
wait: false + polling GET | Only if you genuinely cannot receive an inbound request. Poll at intervals of seconds, not milliseconds |