Guides
Webhooks
Instead of polling, register a URL and Genni will POST to it when one of your documents changes status. Add webhooks in the portal under Webhooks, or through the API:
curl https://api.genni.scribblegreen.com/v1/webhooks -H "Authorization: Bearer $GENNI_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-app.example/genni", "events": ["document.completed", "document.in_review"]}'The response includes a signing secret (whsec_…), shown once. Leave events empty to receive all of them. URLs must be public https addresses.
| Event | Sent when |
|---|---|
document.completed | Figures are extracted and every check passed, or review finished. |
document.in_review | Figures are extracted but a check needs a person. |
document.analyzed | Processing finished without extracting figures. |
document.failed | Processing failed after retries. |
What Genni sends
POST /genni HTTP/1.1
Content-Type: application/json
Genni-Event: document.completed
Genni-Delivery: 7d6c0a57-…
Genni-Signature: t=1790505600,v1=5f2b…
{"id": "7d6c0a57-…", "type": "document.completed", "created_at": "2026-09-27T16:05:00Z",
"data": {"document": {"id": "…", "status": "completed", "company_name": "Zenith Bank Plc", "fiscal_year": 2024,
"external_ref": "doc-8841", "open_review_items": 0, "figures_url": "/v1/documents/…/figures", …}}}Verify the signature
Compute an HMAC-SHA256 of t + "." + raw body with your secret and compare it with v1. Use the raw request body, before any JSON parsing, and reject timestamps older than a few minutes.
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)import crypto from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}Retries
Reply with any 2xx status within 10 seconds. Otherwise Genni retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then marks the delivery failed. The Genni-Delivery ID stays the same across retries, so you can ignore repeats. Every attempt appears in the delivery log.