Vaybel's generation tools are asynchronous: they return a handle immediately
and the real work runs for seconds to minutes. Instead of polling
<domain>.get_generation, pass a callback_url and Vaybel will POST a signed
event to it the moment the work reaches a terminal state.
When to use it
- Long-running work you don't want to babysit — designs, mockups, videos, and imports all finish out-of-band. A webhook frees your agent from a polling loop and the 50-second long-poll cap.
- Server-side automations — n8n, a Lambda, or your own backend can react to completion without holding an MCP connection open.
Keep polling when your caller can't accept an inbound HTTP request (a laptop
behind NAT, a notebook session). The two are interchangeable — the webhook
data field is byte-for-byte what a get_generation poll returns.
Tools that accept callback_url
design.generate, mockup.generate, product_video.generate,
virtual_model.generate, listing.create, listing.publish,
content.generate, trend.generate_concept, and optimize.run.
The parameter is always optional. Omit it and nothing changes. Supply it and it
must be a public https URL (Vaybel refuses loopback, private, and
.internal/.local hosts). Pass it just like any other argument:
{
"product_uuid": "a1b2c3d4-…",
"prompt": "retro sunset over palm trees",
"callback_url": "https://hooks.your-app.com/vaybel"
}The event
When the task finishes, Vaybel sends one POST:
POST /vaybel HTTP/1.1
Content-Type: application/json
User-Agent: Vaybel-Webhooks/1
X-Vaybel-Event: design.completed
X-Vaybel-Delivery: 6f5c1e2a-8b4d-4a1e-9c77-2f0b9a3d5e10
X-Vaybel-Signature: t=1757300000,v1=1d4e…c9{
"event": "design.completed",
"handle": "3b8e0c2a-1f4d-4c9a-9b21-7e5a0d2c6f10",
"tool": "design.generate",
"organization_id": "8c2b…",
"delivered_at": "2026-09-07T18:13:20.512Z",
"data": {
"handle": "3b8e0c2a-1f4d-4c9a-9b21-7e5a0d2c6f10",
"resource_id": "d91a…",
"status": "complete",
"done": true,
"design_id": "d91a…",
"progress": 1.0,
"stage": "Design ready",
"image_url": "https://cdn.vaybel.com/…",
"error": ""
}
}eventis<category>.completedon success or<category>.failedon a failed/cancelled task. Branch on it, or on thedata.statusfield.handlematches the handle the tool returned, so you can correlate the event with the call you made.toolis the exact tool name that was invoked.datais the full projection — identical to<domain>.get_generation(handle).
Headers
| Header | Meaning |
|---|---|
X-Vaybel-Event | The event name, same as the body's event. |
X-Vaybel-Delivery | Unique id for this delivery attempt. |
X-Vaybel-Signature | t=<unix seconds>,v1=<hex hmac-sha256> — verify before trusting the body. |
Verifying the signature
The signature covers "<t>.<raw request body>" with HMAC-SHA256 keyed by your
org's signing secret. Verify against the raw bytes you received — parsing
and re-serializing the JSON changes the bytes and breaks the check.
import hmac, time
from hashlib import sha256
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, signature = parts["t"], parts["v1"]
if abs(time.time() - int(timestamp)) > tolerance:
return False # stale — reject replays
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, sha256
).hexdigest()
return hmac.compare_digest(expected, signature)const crypto = require('crypto');
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const { t, v1 } = parts;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Replay protection
The t value is when Vaybel signed the request. Reject anything older than your
tolerance (300 seconds is a good default) so a captured delivery can't be
replayed later. Deduplicate on X-Vaybel-Delivery if your handler is not
idempotent — a retried delivery reuses the same event but carries a new id.
Retries
A delivery counts as accepted only on a 2xx response. Any other status, a
timeout (10s), or a connection error is retried on a fixed backoff:
| Attempt | Delay before it |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After the fifth attempt Vaybel gives up. Every attempt — including the failures
— is recorded under Dashboard → Settings → MCP → Completion webhooks, so you
can see exactly what was sent and how your endpoint answered.
Getting and rotating your secret
Open Dashboard → Settings → MCP, scroll to Completion webhooks, and press
Reveal to see the whsec_… signing secret (it is minted the first time you
ask for it). Store it as an environment variable on your receiver.
Press Rotate to replace it. The old secret stops verifying immediately, so update your receiver with the new value first. The signing secret is per organization and covers every webhook that org sends.
Recipe: n8n
Add a Webhook trigger
Drop a Webhook node, set the method to POST, and copy its production URL. Under the node's options set Binary Data / Raw Body on so you can verify the signature against the exact bytes.
Pass the URL as callback_url
In the MCP Client node that runs design.generate (or any async tool), add callback_url to the JSON input: ={{ ({ product_uuid: $json.uuid, prompt: "retro sunset", callback_url: "https://your-n8n.app/webhook/vaybel" }) }}.
Verify in a Code node
After the Webhook trigger, add a Code node that recomputes the HMAC over `${t}.${rawBody}` with your whsec_… secret and compares it to the v1 value in the X-Vaybel-Signature header. Stop the workflow when it doesn't match.
Act on the event
Read body.data — it is the same shape as design.get_generation. Branch on body.event (design.completed vs design.failed) and continue the flow (generate mockups, publish, notify).