Skip to content

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:

  1. 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.
  2. Comparing with ==. A non-constant-time comparison leaks the signature one byte at a time.
  3. 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 200

If every delivery fails, the response is still there: fetch it at GET /v1/completions/{id}.

Requirements for your endpoint

SchemeHTTPS only. An http:// URL is rejected at submission
AddressA public internet address. Private and local addresses are refused
Response2xx as fast as you can — within 10 seconds. Do the work asynchronously
Slow handlerCounts as a failure and earns a retry — so acknowledge first, work second

Webhook or long poll?

UseWhen
wait: trueInteractive work, a script you are watching, development. Simplest to write
wait: false + callback_urlEverything unattended. Batches, schedules, queue workers, anything where Revoye Desk being offline should mean "later" and not "error"
wait: false + polling GETOnly if you genuinely cannot receive an inbound request. Poll at intervals of seconds, not milliseconds