Configure up to 5 HTTPS endpoints in Settings → API & Webhooks — one per environment (production, staging, development) and beyond. Every time a generation kicked off through /api/v1/generate completes or fails, we POST a signed JSON payload to each enabled endpoint — no polling required.
Image generations finish in a few seconds, but video can take 60–180 s. Polling GET /api/v1/generations/:idin a tight loop wastes your rate-limit budget and adds latency between “ready” and “your user sees it”. With a webhook, we push the result the instant the generation finishes — typical end-to-end latency is under 200 ms from generation completion to your endpoint receiving the POST.
VIVIX_WEBHOOK_SECRET).Every delivery is a POST with a JSON body. The same shape applies to both generation.completed and generation.failedevents — failed events have null output URLs, a populatederror_code / error_message, and aretryable boolean telling you whether resubmitting the same request would succeed.
POST https://api.your-app.com/vivix/webhook
Content-Type: application/json
User-Agent: Vivix-Webhook/1.0
X-Vivix-Signature: sha256=4f8b…
X-Vivix-Event: generation.completed
X-Vivix-Delivery-Id: c17a4e6d-9b2f-4c8a-8d31-7f0e9a5c2b46
{
"generation_id": "5b91a581-ee19-4f86-9fea-bd29471d69d5",
"status": "completed",
"model": "grok-imagine-image-quality",
"output_url": "https://getvivix.com/api/v1/output/5b91a581-ee19-4f86-9fea-bd29471d69d5/1758734625/a1b2c3d4e5f60718293a4b5c6d7e8f9/0/getvivix-5b91a581.png",
"output_urls": ["https://getvivix.com/api/v1/output/5b91a581-ee19-4f86-9fea-bd29471d69d5/1758734625/a1b2c3d4e5f60718293a4b5c6d7e8f9/0/getvivix-5b91a581.png"],
"credits_charged": 12,
"duration_ms": 4321,
"completed_at": "2026-05-12T18:23:45.123Z"
}output_url / output_urls are signed, getvivix-hosted links, not the raw provider file — and they expire 3 days after the generation completes. Save the bytes to your own storage before then; polling GET /api/v1/generations/:id mints a fresh link if you need one later.
| Header | Purpose |
|---|---|
X-Vivix-Signature | HMAC-SHA256 of the raw request body, hex-encoded, prefixed with sha256=. Verify with constant-time compare. |
X-Vivix-Event | Either generation.completed or generation.failed. More event types coming as we add features. |
X-Vivix-Delivery-Id | UUID unique to this delivery attempt — a fresh one every retry. Log it for correlation; use generation_id in the body for dedup (see Idempotency below). |
User-Agent | Always Vivix-Webhook/1.0. Allowlist on your CDN / WAF if you have one. |
Always verify before trusting the payload. We sign the raw request body with HMAC-SHA256. Use a constant-time compare to defeat timing-attack-based secret discovery.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// IMPORTANT: pass the RAW body buffer to the verifier — JSON-parsing first
// then re-stringifying will change byte order and break the signature.
app.post(
'/vivix/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.header('X-Vivix-Signature') ?? ''
const sent = header.replace(/^sha256=/, '')
const mac = crypto
.createHmac('sha256', process.env.VIVIX_WEBHOOK_SECRET!)
.update(req.body)
.digest('hex')
const a = Buffer.from(sent, 'hex')
const b = Buffer.from(mac, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature')
}
const event = JSON.parse(req.body.toString('utf8'))
// …handle event.status, event.output_url, etc.
res.status(200).end()
},
)import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["VIVIX_WEBHOOK_SECRET"].encode()
@app.post("/vivix/webhook")
def vivix_webhook():
sent = request.headers.get("X-Vivix-Signature", "").removeprefix("sha256=")
mac = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
# constant-time compare — protects against timing-attack secret discovery
if not hmac.compare_digest(sent, mac):
abort(401)
event = request.get_json(force=True)
# …handle event["status"], event["output_url"], etc.
return "", 200We give your endpoint 5 seconds to respond. A timeout, a hang-up, or a 5xx all count as a transport failure and are retried per the policy below — they are not a dead end. Make your handler fast — enqueue the work, then return 200 immediately, so a slow downstream job never turns into an avoidable retry.
We retry up to 3 attempts with exponential backoff when delivery hits a transport error (timeout, DNS failure, TCP reset, etc.) or your endpoint returns a 5xx status code. A 2xx or 3xx stops the chain as success. A 4xxstops the chain as a terminal failure — we assume the customer's endpoint is rejecting the payload on purpose and retrying won't change that.
| Attempt | Wait before this attempt | Total elapsed |
|---|---|---|
| 1 | none — fired immediately | 0s |
| 2 | 1s | ~1s after attempt 1 |
| 3 | 5s | ~6s after attempt 1 |
Each attempt then gets its own 5-second response window (see Timeouts above), so the whole chain normally finishes in under 25 seconds. As a safety valve, if something delays the chain so long that 90 seconds have already passed before the next attempt would start — rare, well outside the schedule above — we abandon it early and log error_message = “retry budget exhausted” instead of trying again.
Each attempt is its own row in the recent-deliveries log (attempt = 1 | 2 | 3), each with its own fresh X-Vivix-Delivery-Id — useful for correlating one try with your own logs, but not a stable key across retries. See Idempotency below for the field that is.
GET /api/v1/generations/:idas a fallback for any generation that didn't arrive.Each retry (see above) is a genuinely new delivery with its own fresh X-Vivix-Delivery-Id, so that header can't tell you “I've seen this before.” Dedupe on generation_id instead — it stays identical across every attempt for the same event, so if your endpoint already processed one and we retried anyway (your 200 got lost in transit, for example), the retry is recognizably the same generation.
const seen = new Set<string>()
app.post('/vivix/webhook', (req, res) => {
const event = JSON.parse(req.body.toString('utf8'))
if (seen.has(event.generation_id)) return res.status(200).end() // already handled
seen.add(event.generation_id)
// …handle
res.status(200).end()
})In production, back seen with Redis or a database table with a unique index on generation_id. Keep X-Vivix-Delivery-Id around too — log it alongside each attempt so a support request can point at the exact delivery row.
The Recent deliveries panel in Settings shows the last 10 attempts — HTTP status, response time, and any transport error. Common failure modes:
| Symptom | Likely cause | Fix |
|---|---|---|
Status 401 from your endpoint | Signature verification failed. | Make sure you're hashing the RAW body bytes, not the JSON-reparsed form. |
Status — + “Timeout after 5000ms” | Your handler is doing too much work synchronously. | Queue the work (BullMQ, SQS, etc.) and return 200 immediately. |
Status — + DNS / certificate error | URL is unreachable or has a bad TLS cert. | Verify the URL resolves over HTTPS publicly. Self-signed certs are not accepted. |
Status 404 | Path no longer exists on your server. | Update the URL in Settings → API & Webhooks. |
Status 5xx | Your handler crashed. | Check your server logs — the request was well-formed by the time we sent it. |
error_code