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 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:

  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

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 200

Requirements for your endpoint

SchemeHTTPS only. An http:// URL is rejected at submission
Response2xx as fast as you can. Do the work asynchronously
Slow handlerCounts as a failure and earns a retry — so acknowledge first, work second
FailuresRetried with backoff

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 an offline device 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