# Webhooks `POST /api/hooks/render` renders an image the same way `POST /api/generate` does, and accepts the same request body, plus an optional `webhookUrl` for push-style delivery once the render completes. ## Render with a webhook **Request** ```bash curl -X POST https://yourdomain.com/api/hooks/render \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "templateId": "your-template-id", "layers": { "title": { "text": "Order shipped" } }, "webhookUrl": "https://yourapp.com/webhooks/renderfast" }' ``` **Response** ```json { "success": true, "imageUrl": "https://yourdomain.com/api/images/generated/...", "cached": false, "generationTimeMs": 842 } ``` The response is synchronous: it's returned as soon as the render finishes, whether or not a webhook is configured. If `webhookUrl` is set, the exact same JSON body is also POSTed to that URL after the response has already been sent. > **Note: webhookUrl constraints** > > `webhookUrl` must be a public `http:` or `https:` URL. Loopback, private, and link-local hosts (and cloud metadata addresses) are rejected with a **400** before any render work happens. If you haven't generated a webhook secret yet, a request that sets `webhookUrl` also gets a **400**, since there'd be nothing to sign the delivery with. ## Generating a webhook secret Webhook deliveries are signed with a per-account secret. Generate one before using `webhookUrl` for the first time: ```bash curl -X POST https://yourdomain.com/api/settings/webhook-secret \ -H 'Authorization: Bearer YOUR_API_KEY' # → 200 { "success": true, "webhookSecret": "whsec_..." } # Store this now: it's returned once and there is no read-back endpoint. # Calling this again rotates the secret and invalidates the old one immediately. ``` You can also generate or rotate this secret from the app, under [API Keys](/app/api-keys), without calling the endpoint directly. > **Warning: Rotating replaces the old secret** > > There is only ever one active secret per account. Calling this endpoint again generates a new one and immediately invalidates the previous secret, there's no overlap window where both work. Update your webhook verification code before rotating in production. ## Webhook delivery and signature A webhook delivery is a `POST` of the same JSON payload returned in the render response, with a signature header: ``` X-Renderfast-Signature: t=,v1= ``` timestamp and raw request body being sent. > **Note: Delivery guarantees** > > Webhook delivery uses a 10 second timeout and is not retried in v1. A slow or failing webhook endpoint never affects the original render response, that response has already been sent by the time delivery is attempted. ### What your endpoint receives Your `webhookUrl` gets exactly one delivery per render call: a `POST` request with a JSON body and these headers, plus the signature header described above. ``` POST https://yourapp.com/webhooks/renderfast Content-Type: application/json X-Renderfast-Signature: t=1757200000,v1=5f8a...c091 { "success": true, "imageUrl": "https://yourdomain.com/api/images/generated/...", "cached": false, "generationTimeMs": 842 } ``` The body is identical to the render response: same `success`, `imageUrl`, `cached`, and `generationTimeMs` fields. A `warnings` array is included only when the render produced at least one warning. It's omitted entirely otherwise. There's no retry. A 10 second delivery timeout applies, and a failed or slow endpoint on your side doesn't get a second attempt in v1. ### Verifying a webhook ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; function verifyRenderfastSignature(header, rawBody, secret) { const parts = Object.fromEntries( header.split(',').map((part) => part.split('=')) ); const timestamp = parts.t; const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(parts.v1, 'utf8'); return a.length === b.length && timingSafeEqual(a, b); } ```