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:
| Operation | What it does | Starts generation |
|---|---|---|
GET /recipes · GET /recipes/{recipeId} | Search and read approved Recipes and their declared ports | No |
POST /recipes/{recipeId}/fork | Creates one private, editable Recipe instance on a canvas | No |
POST /recipes/{recipeId}/runs | Binds inputs, materializes output nodes, queues work | Yes |
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:
nodeType | Accepts |
|---|---|
image | A durable image handle — node: or asset:. Plain text is not valid. |
text · sticky | Plain 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.pngPOST /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:
| Field | Meaning |
|---|---|
key · label · type | The declared port |
status | pending, running, succeeded, failed, canceled |
nodeId · nodeHandle | Durable canvas node — present from the start |
assetId | Durable asset, for image outputs |
value | Text content, up to 24000 characters |
jobId · runHandle | The child image job, when one produced this output |
startedAt · completedAt | Per-output timing |
failure | Per-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.code | Meaning | What to do |
|---|---|---|
recipe_input_invalid | An input did not match its declared port | Fix the binding. Re-read the Recipe’s ports. |
recipe_step_failed | A step failed — usually the underlying image job | Check outputKey, then the child Run |
recipe_output_missing | A declared output produced nothing | Inspect the Run’s outputs |
recipe_run_canceled | Canceled by a caller | Nothing |
delegation_revoked | The Agent Access token behind the Run was revoked or expired mid-Run | Create 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.
Related
- Webhooks — headers, retry schedule, delivery state
- Generate and observe an image — the single-step polling alternative
- Recipes · Runs — the generated contracts and callback declarations
- Authentication — why
image:generateis required even for a text-only Recipe