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 finished request to it, so your code does not have
to hold a connection open while the response is produced. This is the right shape for anything
unattended: batch work, scheduled jobs, or a queue worker.
curl https://api.revoye.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. When the request finishes, Revoye delivers it to your endpoint.
The delivery
POST /hooks/revoye HTTP/1.1
X-Revoye-Signature: sha256=<hex>
X-Revoye-Timestamp: 1760346842
Content-Type: application/json
{ "id": "job_01JAY7Q2K8XYZ", "status": "succeeded", "response": "…", "metadata": { "release_id": "r_2291" } }The body is the same object /v1/completions returns — including your
metadata, unchanged, which is how you route it without a database lookup. A request that ended
without a response is delivered too, with its final status and an error saying why:
{ "id": "job_01JAY7Q2K8XYZ", "status": "failed", "response": null, "channel": "cloud",
"error": { "code": "JOB_FAILED", "message": "The job could not be completed. It is safe to retry." },
"metadata": { "release_id": "r_2291" } }error is null for a request that succeeded. Otherwise it carries the same code and message
a wait: true request for that job is answered with — JOB_FAILED, JOB_CANCELLED,
JOB_TIMEOUT and the rest of the error codes — so one handler serves both.
Verify the signature
Verify before you parse. The URL is public; anything can POST to it.
Your webhook signing secret is on the API keys page of the dashboard, under Webhook signing
secret — it starts with whsec_, and it is yours alone. Compute HMAC-SHA256 over the exact
string "{timestamp}.{body}" using that secret, then compare in constant time.
Rotating the secret on the same page replaces it at once: webhooks signed with the old secret stop verifying, so update your receiver straight after you rotate.
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
A failed delivery is retried: five attempts in all, at roughly 0 seconds, 30 seconds, 5 minutes, 30 minutes and 2 hours. 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 200If every delivery fails, the response is still there: fetch it at GET /v1/completions/{id}.
Requirements for your endpoint
| Scheme | HTTPS only. An http:// URL is rejected at submission |
| Address | A public internet address. Private and local addresses are refused |
| Response | 2xx as fast as you can — within 10 seconds. Do the work asynchronously |
| Slow handler | Counts as a failure and earns a retry — so acknowledge first, work second |
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 Revoye Desk being offline 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 |