RoomagenDevelopersGet API key

Guide

Webhooks

Delivery contract, signature verification and the retry schedule.

Set webhook_url on a job, or an account-wide endpoint in the portal, and Roomagen POSTs the finished job to you instead of making you poll.

The contract

Every value below is read from the API contract itself, so this page cannot drift from what the dispatcher does.

Delivery

Type
POST · application/json
Timeout
10s

webhook_url on the job, otherwise the account webhook configured in the developer portal. Both must be public https URLs.

any 2xx status. Anything else, including a timeout, counts as a failed attempt.

EventDescription
job.completed

Generation succeeded; result_urls is populated.

job.failed

Generation failed; credits were refunded and error explains why.

job.test

Sent by "Send test event" in the developer portal. Fixed sample payload — never a real job.

HeaderDescription
X-Roomagen-Event

One of job.completed, job.failed, job.test.

X-Roomagen-Timestamp

Unix time in seconds when the request was signed.

X-Roomagen-Signature

v1=<hex>, the HMAC described below.

Signature

Algorithm
HMAC-SHA256, hex-encoded
Signed payload
{X-Roomagen-Timestamp}.{raw request body}
Header format
v1=<hex digest>

The webhook signing secret for your API client, prefixed whsec_, shown in the developer portal. Rotating it invalidates the previous secret immediately.

Recompute the digest over the RAW body bytes (never the re-serialised JSON), compare with a constant-time comparison, and reject timestamps older than about five minutes to blunt replays.

Retries

Max attempts
5
Total window
about 36 minutes, after which the job is abandoned

Backoff after a failed attempt

15s60s5m30m

A destination that fails the public-https check (private, loopback or link-local host) is dropped immediately rather than retried.

Delivery semantics

At-least-once. A delivery that times out after your server has already processed it will be retried, so consumers MUST de-duplicate on job_id and treat handlers as idempotent.

None guaranteed. Do not assume a webhook arrives after your own POST /v1/jobs call has returned, and do not rely on ordering between jobs.

Verifying a delivery

Recompute the digest over the raw body bytes — never the re-serialised JSON, because key order and whitespace will not survive a round trip and the digest will not match. Compare in constant time, then check the timestamp.

Both versions below lower-case the header names before reading them: HTTP header names are case-insensitive, and proxies and frameworks disagree about the casing they hand you.

js
const crypto = require("crypto");

function verify(rawBody, headers, secret) {
  const h = Object.fromEntries(
    Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]),
  );
  const ts = h["x-roomagen-timestamp"];
  const got = h["x-roomagen-signature"];

  // A malformed or missing header is a failed verification, not an exception:
  // timingSafeEqual throws on a length mismatch, so check before calling it.
  if (typeof ts !== "string" || typeof got !== "string") return false;

  const expected =
    "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  if (got.length !== expected.length) return false;
  if (!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return false;

  // Reject replays: the signature stays valid forever, the timestamp does not.
  const age = Math.abs(Date.now() / 1000 - Number(ts));
  return Number.isFinite(age) && age < 300;
}

The same check in Python:

python
import hmac, hashlib, time
from typing import Mapping

def verify(raw_body: bytes, headers: Mapping[str, str], secret: str) -> bool:
    h = {k.lower(): v for k, v in headers.items()}
    ts = h.get("x-roomagen-timestamp")
    got = h.get("x-roomagen-signature")
    if ts is None or got is None:
        return False

    signed = f"{ts}.".encode() + raw_body
    expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # compare_digest is constant-time and safe on unequal lengths.
    if not hmac.compare_digest(expected, got):
        return False

    # Reject replays: the signature stays valid forever, the timestamp does not.
    try:
        return abs(time.time() - int(ts)) < 300
    except ValueError:
        return False

Reject anything that fails either check, and respond 2xx only once you have accepted the delivery. Any other status — including a timeout — counts as a failed attempt.

Testing your endpoint

Use Send test event in the portal to fire a job.test delivery at your configured URL. It carries a fixed sample payload and is never a real job, so it is safe to point at a staging environment while you get the signature check right.

Delivery guarantees

Delivery is at-least-once. A delivery that times out after your server has already processed it will be retried, so de-duplicate on job_id and make your handler idempotent.

No ordering is guaranteed. Do not assume a webhook arrives after your own POST /v1/jobs call has returned, and do not rely on ordering between jobs.

Delivery is not guaranteed forever

After the retry window in the contract above is exhausted, the job is abandoned and Roomagen stops trying. Webhooks are an optimisation, not a system of record — if a result matters, reconcile with GET /v1/jobs/{id} for anything you never received.

Next step

Errors & limits — every error code and what to do about it.