Skip to Content
APIRecipesRun a Recipe with a webhook

Run a Recipe with a webhook

A Recipe is a reusable workflow in the Gavana Recipe Library: named typed input ports, named typed output ports, and sequential text or image work in between. It is the right shape for “produce this deliverable from these ingredients”, where a raw image call is the right shape for “make one picture”.

Because a Recipe runs several steps, it runs long. A signed webhook is the way to find out it finished without holding a process open.

This recipe spends provider credit. POST /recipes/{recipeId}/runs queues paid work when it returns 202. Get explicit approval in the current turn before the start call, and never auto-retry after a failure.

Scopes: canvas:read, canvas:write, asset:read, image:generate. The Recipe start route requires image:generate for every Recipe, including a text-only one. Add job:manage to poll or cancel, or to inspect webhook delivery state. You end up with: typed outputs, each carrying a durable node: handle, plus asset: for image outputs and value for text outputs.

The in-app Recipe Builder and Recipe Library entry points are currently disabled in the Gavana browser UI while the feature is finished. The API operations described here remain part of the V1 contract, but GET /recipes may return nothing on a production account until the library is populated.

Discovery, forking, and running are three different things

This trips people up, so it is worth stating plainly:

OperationWhat it doesStarts generation
GET /recipes · GET /recipes/{recipeId}Search and read approved Recipes and their declared portsNo
POST /recipes/{recipeId}/forkCreates one private, editable Recipe instance on a canvasNo
POST /recipes/{recipeId}/runsBinds inputs, materializes output nodes, queues workYes

Forking never triggers a run. If you want a copy to edit, fork. If you want output, run. Doing both is only necessary when you intend to customise the workflow first.

The sequence

Find a Recipe

GET /api/canvas-agent/v1/recipes?q=product%20launch&limit=100 Authorization: Bearer cba_…
{ "recipes": [ { "id": "9f3c2a71", "handle": "recipe:9f3c2a71", "version": "3", "name": "Product launch pack", "summary": "Turns a product photo and a positioning note into a hero image and three captions.", "tags": ["marketing", "product"], "inputs": [ ], "outputs": [ ] } ], "page": { "limit": 100, "hasMore": false, "nextCursor": null } }

Read its ports

You cannot guess input names. Read them.

GET /api/canvas-agent/v1/recipes/9f3c2a71 Authorization: Bearer cba_…

Pass ?version=3 to pin an immutable version. Omit it and you get the current one — which means a Recipe update can change your Run’s behaviour. Pin it in production.

{ "recipe": { "id": "9f3c2a71", "handle": "recipe:9f3c2a71", "version": "3", "name": "Product launch pack", "description": "…", "inputs": [ { "nodeId": "a1b2c3d4", "key": "product-photo", "label": "Product photo", "description": "A clean photo of the product on a plain surface.", "useAsPreset": false, "nodeType": "image" }, { "nodeId": "e5f6a7b8", "key": "positioning", "label": "Positioning note", "description": "One paragraph on tone and audience.", "useAsPreset": false, "nodeType": "text" } ], "outputs": [ { "nodeId": "c9d0e1f2", "key": "hero", "label": "Hero image", "description": "…", "useAsPreset": false, "nodeType": "image" }, { "nodeId": "a3b4c5d6", "key": "captions", "label": "Captions", "description": "…", "useAsPreset": false, "nodeType": "sticky" } ] } }

nodeType tells you what each port accepts:

nodeTypeAccepts
imageA durable image handle — node: or asset:. Plain text is not valid.
text · stickyPlain text, a node: handle for a text or sticky node, or — where the Recipe’s description says so — an image node: or asset: handle when the port is a written reference port.

useAsPreset: true means the port has a saved default and can be omitted on a run.

Prepare inputs and a destination

Every image input needs a durable handle. If your source is a local file, upload it first:

POST /api/canvas-agent/v1/assets?canvasId=product-launch&ownerUid=9Kd2Jv7QpR Authorization: Bearer cba_… Content-Type: image/png X-Gavana-File-Name: bottle.png

POST /assets needs asset:read plus at least one of image:generate or video:generate, and — because canvasId is present here — canvas:read and canvas:write as well.

Then read the destination canvas for its current revision. See Read a canvas.

Confirm before spending

This runs Product launch pack v3 on Product launch, producing 1 hero image and 1 caption block, charging your image provider. Proceed?

Start the Run with a webhook

POST /api/canvas-agent/v1/recipes/9f3c2a71/runs Authorization: Bearer cba_… Content-Type: application/json
{ "canvasId": "canvas:9Kd2Jv7QpR:product-launch", "baseRevision": "2026-08-04T09:21:07.554213Z", "idempotencyKey": "product-launch-pack-2026-08-04-001", "version": "3", "inputs": { "product-photo": "asset:9Kd2Jv7QpR:7d3e9f21", "positioning": "Quiet premium. Speaks to people who buy once and keep it for a decade." }, "webhook": { "url": "https://automation.example.com/hooks/gavana", "secret": "a-caller-owned-secret-of-at-least-32-characters" } }

Input keys are the declared input node id or normalized label — use the key from the Recipe when it has one. At most 16 inputs; each value is a string of 1 to 8000 characters. model, size, and quality are optional overrides for image outputs.

Pass instanceNodeId instead of letting Gavana create one when you have already forked a private Recipe onto the destination canvas and want to run that one.

{ "schemaVersion": 1, "id": "recipe-6a1b2c3d", "rawId": "recipe-6a1b2c3d", "handle": "run:recipe-6a1b2c3d", "run": "run:recipe-6a1b2c3d", "kind": "recipe", "operation": "recipe", "status": "queued", "recipeId": "9f3c2a71", "recipeHandle": "recipe:9f3c2a71", "recipeVersion": "3", "recipeInstanceNodeId": "f7a8b9c0", "recipeInstanceNodeHandle": "node:f7a8b9c0", "canvasId": "product-launch", "canvasHandle": "canvas:9Kd2Jv7QpR:product-launch", "canvasUrl": "https://app.gavana.ai/canvas/product-launch", "inputs": [ { "key": "product-photo", "label": "Product photo", "type": "image", "nodeId": "d1e2f3a4", "nodeHandle": "node:d1e2f3a4", "sourceHandle": "asset:9Kd2Jv7QpR:7d3e9f21", "summary": "bottle.png" } ], "outputs": [ { "key": "hero", "label": "Hero image", "type": "image", "status": "pending", "nodeId": "b5c6d7e8", "nodeHandle": "node:b5c6d7e8" }, { "key": "captions", "label": "Captions", "type": "sticky", "status": "pending", "nodeId": "f9a0b1c2", "nodeHandle": "node:f9a0b1c2" } ], "targets": ["node:b5c6d7e8", "node:f9a0b1c2"], "currentStep": "queued", "estimatedSeconds": 95, "timing": { "createdAt": "2026-08-04T09:30:02.118Z" }, "pollUrl": "/api/canvas-agent/v1/runs/recipe-6a1b2c3d", "createdAt": "2026-08-04T09:30:02.118Z", "updatedAt": "2026-08-04T09:30:02.118Z", "replayed": false }

The output nodes already exist on the canvas, in pending. Their nodeHandle values are durable from this moment — you can record where the result will land before it lands.

Receive and verify the callback

Gavana posts one signed event when the Run reaches a terminal state.

{ "id": "event:webhook:4d5e6f7a…", "type": "recipe_run.succeeded", "apiVersion": "v1", "createdAt": "2026-08-04T09:31:44.902Z", "data": { "run": { "id": "run:recipe-6a1b2c3d", "status": "succeeded", "operation": "recipe", "recipeId": "recipe:9f3c2a71", "recipeVersion": "3", "canvasId": "canvas:9Kd2Jv7QpR:product-launch", "outputs": [ { "key": "hero", "status": "succeeded", "nodeId": "node:b5c6d7e8", "assetId": "asset:9Kd2Jv7QpR:3e4f5a6b" }, { "key": "captions", "status": "succeeded", "nodeId": "node:f9a0b1c2" } ] } } }

Verify before you parse. The signature is the lowercase hex HMAC-SHA256 of <Gavana-Webhook-Timestamp>.<raw body>, keyed with your secret:

import { createHmac, timingSafeEqual } from 'node:crypto' export async function POST(request: Request) { const rawBody = await request.text() // raw bytes, before JSON.parse const timestamp = request.headers.get('gavana-webhook-timestamp') const signature = request.headers.get('gavana-webhook-signature') const deliveryId = request.headers.get('gavana-webhook-id') if (!timestamp || !signature || !/^\d{10,13}$/.test(timestamp)) { return new Response('bad request', { status: 400 }) } // Replay window. Each delivery attempt is signed fresh, so a few minutes is plenty. if (Math.abs(Date.now() - Number(timestamp) * 1000) > 300_000) { return new Response('stale timestamp', { status: 400 }) } 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 new Response('invalid signature', { status: 401 }) } const expected = createHmac('sha256', process.env.GAVANA_WEBHOOK_SECRET!) .update(`${timestamp}.${rawBody}`, 'utf8') .digest('hex') // Constant-time. A plain === leaks how much of a guess was correct. const a = Buffer.from(expected, 'hex') const b = Buffer.from(supplied, 'hex') if (a.length !== b.length || !timingSafeEqual(a, b)) { return new Response('invalid signature', { status: 401 }) } const event = JSON.parse(rawBody) // Acknowledge fast — each attempt has a 10-second timeout. Do the work later. await enqueueRecipeRunEvent({ deliveryId, eventId: event.id, event }) return new Response(null, { status: 204 }) }

Returning 401 from your own signature check permanently stops delivery — non-retryable 4xx responses end the attempt sequence. That is correct behaviour for a genuinely bad signature, and painful while you are debugging. Test verification against a captured payload before pointing a real Run at the endpoint.

Store the durable outputs

for (const output of event.data.run.outputs) { if (output.status !== 'succeeded') continue await save({ key: output.key, nodeHandle: output.nodeId, assetHandle: output.assetId }) }

nodeId is durable for every output type. assetId is present for image outputs. Text output values are not in the callback payload — read the Run to get them.

Reading the Run for full output values

The callback is a notification. For text output content, timing, or per-output failures, read the Run with job:manage:

GET /api/canvas-agent/v1/runs/recipe-6a1b2c3d Authorization: Bearer cba_…

Each entry in outputs carries more than the callback does:

FieldMeaning
key · label · typeThe declared port
statuspending, running, succeeded, failed, canceled
nodeId · nodeHandleDurable canvas node — present from the start
assetIdDurable asset, for image outputs
valueText content, up to 24000 characters
jobId · runHandleThe child image job, when one produced this output
startedAt · completedAtPer-output timing
failurePer-output failure, when this specific output failed

Outputs fail independently. A Run can be failed overall while one output succeeded — check per-output status rather than assuming the Run status applies uniformly.

Polling instead

A webhook is not mandatory. Poll GET /runs/{runId} on the run: handle exactly as you would an image Run — queued, running, then succeeded, failed, or canceled. currentStep and nextStep describe progress through the workflow, and estimatedSeconds sets expectations.

The spec documents the short temporary retention window for image and Action Run records; it does not declare that TTL for Recipe Run records. Either way, the handles you keep should be node: and asset:, not run:.

When it fails

{ "status": "failed", "failure": { "code": "recipe_step_failed", "message": "…", "retryable": true, "outputKey": "hero" } }
failure.codeMeaningWhat to do
recipe_input_invalidAn input did not match its declared portFix the binding. Re-read the Recipe’s ports.
recipe_step_failedA step failed — usually the underlying image jobCheck outputKey, then the child Run
recipe_output_missingA declared output produced nothingInspect the Run’s outputs
recipe_run_canceledCanceled by a callerNothing
delegation_revokedThe Agent Access token behind the Run was revoked or expired mid-RunCreate a new token and start a new Run

delegation_revoked is specific to long-running Runs. When you start a Recipe with a webhook, Gavana stores a revocable single-Run capability and refreshes the delegation behind your token, which is what lets the Run continue after your HTTP request or CLI process exits. Revoke that token mid-Run and the Run fails deliberately — and Gavana still sends the signed failed callback.

Retrying means spending again. Surface failure.retryable, get approval, and use a new idempotency key.

Canceling

DELETE /api/canvas-agent/v1/runs/recipe-6a1b2c3d Authorization: Bearer cba_…

Needs job:manage. Work already dispatched to a provider may still complete and may still be charged.

Forking without running

When you want to customise the workflow before running it:

POST /api/canvas-agent/v1/recipes/9f3c2a71/fork Authorization: Bearer cba_…
{ "canvasId": "product-launch", "ownerUid": "9Kd2Jv7QpR", "version": "3", "baseRevision": "2026-08-04T09:21:07.554213Z", "idempotencyKey": "fork-product-launch-pack-001", "x": 200, "y": 900 }

Note canvasId here is the plain canvas id with ownerUid alongside it, unlike the run route which takes a canvas: handle. Forking needs only canvas:read and canvas:write, returns a standard canvas mutation result, and starts nothing. Run the resulting private instance later by passing its node as instanceNodeId.

Last updated on