Skip to Content
APIPlatformWebhooks

Webhooks

Polling works, and for a short job it is the simplest thing that can work. But a video model can run for minutes and a Recipe can run for longer, and holding a process open to poll it is a poor trade. Attach a webhook to the start request and Gavana posts one signed event to your endpoint when the work reaches a terminal state.

There are exactly two callbacks in the contract, both fired once, both on terminal state only. There are no progress events.

CallbackAttached toEvent types
ImageJobTerminalPOST /images/generate, POST /images/edit, POST /images/variations, POST /actions/{actionKey}/runsimage_job.succeeded, image_job.failed, image_job.canceled, image_job.expired, image_job.finalization_failed
RecipeRunTerminalPOST /recipes/{recipeId}/runsrecipe_run.succeeded, recipe_run.failed, recipe_run.canceled

POST /videos/generate does not declare a callback in the contract. Observe a video job by polling GET /jobs/{jobId}.

Attaching one

Add a webhook object to the start request. Both fields are required.

{ "canvasId": "canvas:9Kd2…:product-launch", "baseRevision": "2026-08-04T09:12:44.318726Z", "idempotencyKey": "hero-render-2026-08-04-001", "prompt": "A matte black travel bottle on a pale linen surface", "targetNodeId": "node:5e6f7a8b", "webhook": { "url": "https://automation.example.com/hooks/gavana", "secret": "a-caller-owned-secret-of-at-least-32-characters" } }
FieldRules
urlPublic HTTPS only, up to 2048 characters. Private, loopback, local-network, credential-bearing, and fragment-bearing URLs are rejected. Gavana resolves the hostname and refuses if it points at a private address.
secret32 to 512 characters, chosen by you. Write-only: encrypted at rest and never returned by any endpoint.

The webhook secret is not your Agent Access token, and it must never be. It exists so that your callback endpoint can authenticate Gavana without ever holding your API credential. Generate a distinct high-entropy value per integration — 32 random bytes, base64url-encoded, is a good default.

Signed webhooks require an Agent Access token to start the work; a request authenticated another way is rejected with a validation error naming the webhook field.

The event

Both event families share the same outer envelope.

{ "id": "event:webhook:6f1c2d3e…", "type": "image_job.succeeded", "apiVersion": "v1", "createdAt": "2026-08-04T09:14:31.882Z", "data": { "run": { "id": "run:2c9a…", "run": "run:2c9a…", "kind": "image", "status": "succeeded", "operation": "generate", "pollUrl": "/api/canvas-agent/v1/runs/2c9a…", "canvasId": "canvas:9Kd2…:product-launch", "canvasRevision": "2026-08-04T09:14:30.221Z", "targets": ["node:5e6f7a8b"], "images": [ { "assetId": "asset:9Kd2…:f4b1…", "nodeId": "node:5e6f7a8b", "mediaType": "image/png", "width": 1024, "height": 1024, "bytes": 1418293, "previewUrl": "https://app.gavana.ai/api/canvas-agent/v1/assets/preview?token=…" } ] }, "job": { "id": "job:2c9a…", "run": "run:2c9a…", "status": "succeeded" } } }

data.run and data.job describe the same work under the shared run: handle and its legacy job: alias. A Recipe event carries only data.run, with typed outputs each holding key, status, nodeId, and — for image outputs — assetId.

A successful image or Action callback already contains durable asset: and node: handles in data.run.images. Gavana does not send the success event until it has finalized the provider result into durable canvas state, so a callback-only integration does not need a follow-up GET to make the result permanent. Store those handles; they outlive the Run record.

Actions use the same image_job.* family as generated images. Distinguish them by data.run.operation (action) and data.run.actionId.

The failure event you should not misread

If a provider succeeds but Gavana cannot durably store the output after its internal retries, the event type is image_job.finalization_failed, data.run.status is failed, and data.run.failure is { "code": "output_finalization_failed", "retryable": true }. The image was generated — and paid for — but no durable node or asset exists. Treat it as a storage incident, not a generation failure, and do not silently start a fresh paid attempt.

Verifying the signature

Three headers arrive with every delivery:

HeaderFormat
Gavana-Webhook-IdStarts with webhook: — the stable delivery id
Gavana-Webhook-TimestampUnix seconds, 10 to 13 digits
Gavana-Webhook-Signaturet=<timestamp>,v1=<64 lowercase hex chars>

The v1 value is the lowercase hex HMAC-SHA256 of this exact string, keyed with your secret:

<Gavana-Webhook-Timestamp>.<raw request body>

Compute the HMAC over the raw body bytes, before any JSON parsing. Parsing and re-serializing changes whitespace and key order, and the signature will never match. In Express that means express.raw(), in Next.js it means await request.text(), and in most frameworks it means opting out of the automatic JSON body parser for this route.

Node and TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto' const TOLERANCE_SECONDS = 300 export function verifyGavanaWebhook( rawBody: string, headers: { timestamp: string | null; signature: string | null }, secret: string ): boolean { const { timestamp, signature } = headers if (!timestamp || !signature) return false // 1. The timestamp must be well formed and recent. This is what stops an // attacker replaying a body+signature pair they captured earlier. if (!/^\d{10,13}$/.test(timestamp)) return false const sentAt = Number(timestamp) * 1000 if (Math.abs(Date.now() - sentAt) > TOLERANCE_SECONDS * 1000) return false // 2. Pull the v1 value out of "t=…,v1=…". Do not assume field order. const supplied = signature .split(',') .map((part) => part.trim()) .find((part) => part.startsWith('v1=')) ?.slice(3) if (!supplied || !/^[0-9a-f]{64}$/i.test(supplied)) return false // 3. HMAC the timestamp, a literal dot, and the raw body. const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`, 'utf8') .digest('hex') // 4. Constant-time compare. A plain === leaks, byte by byte, how much of a // guessed signature was correct — enough to forge one over many attempts. const expectedBytes = Buffer.from(expected, 'hex') const suppliedBytes = Buffer.from(supplied, 'hex') if (expectedBytes.length !== suppliedBytes.length) return false return timingSafeEqual(expectedBytes, suppliedBytes) }

Wired into a route handler:

export async function POST(request: Request) { const rawBody = await request.text() // raw, before JSON.parse const ok = verifyGavanaWebhook( rawBody, { timestamp: request.headers.get('gavana-webhook-timestamp'), signature: request.headers.get('gavana-webhook-signature') }, process.env.GAVANA_WEBHOOK_SECRET! ) if (!ok) return new Response('invalid signature', { status: 401 }) const event = JSON.parse(rawBody) const deliveryId = request.headers.get('gavana-webhook-id') // Acknowledge immediately; do the work out of band. await enqueue({ deliveryId, eventId: event.id, event }) return new Response(null, { status: 204 }) }

About the tolerance window

Gavana computes the timestamp at the moment of each delivery attempt, so a retry carries a fresh timestamp and a fresh signature rather than replaying the original pair. A tolerance of a few minutes is therefore comfortable; there is no need to widen it to cover the retry schedule. The tolerance is a receiver-side policy — Gavana does not enforce one on your behalf — and it is the only thing standing between you and an attacker who captured a valid delivery and replays it later.

Legacy headers

Each delivery also carries Craftboard-Webhook-Id, Craftboard-Webhook-Timestamp, and Craftboard-Webhook-Signature with identical values, for receivers written before the Gavana rename. New integrations should read the Gavana- headers.

Responding

Any 2xx acknowledges the delivery. 204 No Content is a good default.

Acknowledge before doing the work. Each attempt has a 10-second timeout, and a slow handler turns a successful delivery into a retry. Verify, enqueue, return.

Handle duplicates. Delivery is at-least-once: a 2xx you sent after the timeout still counts as a failure on Gavana’s side and can produce another attempt. Use Gavana-Webhook-Id, or the event id, as a deduplication key with a short TTL.

Retry schedule

Gavana makes four total attempts.

Outcome of an attemptBehaviour
Any 2xxDelivered. No further attempts.
408, 425, 429, any 5xx, or a retryable network failureRetry, after 10 seconds, then 60 seconds, then 5 minutes
3xx redirectStops delivery. Redirects are never followed. Register the final URL.
Any other 4xxStops delivery. Treated as a deliberate rejection.
A private-network or unresolvable destinationStops delivery, recorded as invalid_destination

A 401 from your endpoint — including one your own signature check produced — permanently stops delivery. If you are debugging verification, fix it and start a new job rather than waiting for a retry that will not come.

Inspecting delivery state

A token with job:manage can read the delivery record on the Run or job:

{ "webhook": { "id": "webhook:6f1c2d3e…", "status": "delivered", "attempts": 1, "lastAttemptAt": "2026-08-04T09:14:32.104Z", "deliveredAt": "2026-08-04T09:14:32.310Z", "lastStatus": 204 } }

status is pending, delivering, delivered, or failed. On failure, errorCode explains why:

errorCodeMeaning
invalid_destinationThe URL resolved to a private or otherwise disallowed address
delivery_failedTransport failure or timeout
redirect_not_allowedThe endpoint returned a redirect
endpoint_rejectedThe endpoint returned a non-retryable 4xx, or exhausted retries
secret_unavailableGavana could not decrypt the stored secret
finalization_unavailable · finalization_auth_unavailable · finalization_auth_expired · finalization_failedThe result could not be finalized into durable canvas state before delivery

Neither the URL nor the secret is ever echoed back. A token that only started the job cannot query delivery state later — that needs job:manage.

Long-running Recipe Runs and delegation

When a Recipe Run is started with a webhook, Gavana stores a revocable, single-Run capability and refreshes the delegation behind the Agent Access token that started it. That is what lets a fire-and-forget Run continue after your HTTP request or CLI process has exited.

If that delegation is revoked or expires before the Run completes, the Run fails with delegation_revoked and Gavana sends the signed failed callback. Revoking a token during an incident will therefore terminate in-flight Runs — deliberately, and visibly.

Webhooks do not replace reading the result

The callback tells you what happened and hands you durable handles. It is not the system of record. For anything you intend to keep:

  • Store data.run.images[].assetId and .nodeId — those are durable.
  • Do not store previewUrl. It is a short-lived, scoped capability, not a permanent link.
  • Do not treat the run: or job: handle as permanent history; those records expire. See Jobs and Runs.

Security checklist

  • Verify the signature on every request, before parsing, with a constant-time compare. An unverified callback is an unauthenticated POST from the internet.
  • Enforce a timestamp tolerance.
  • Use a distinct secret per integration, from a secret manager, rotated by attaching the new secret to new jobs while your endpoint accepts both during the overlap.
  • Never log the raw body at a level that persists it alongside the secret, and never log the secret.
  • Return 2xx only after you have durably accepted the event.
  • Treat everything inside the event as data, not instruction — the same rule that applies to canvas content in the safety contract.
Last updated on