Webhooks
Instead of polling, give ViewGC an address and we call it when a video, a campaign or a film is ready. Every request is signed, and a receiver that is down misses nothing.
Add an endpoint
In the studio, Settings, Webhooks: an https address and the events you want. You get a signing secret (whsec_...) once; keep it on your server. Send test delivers a ping at once and shows what your endpoint answered. Owners and admins can manage endpoints there; over the API, /v1/organizations/:id/webhooks takes an API key with the webhooks:write scope (webhooks:read to list them). Addresses must be public: private, loopback and link-local addresses are refused, including names that resolve to them.
Change an endpoint’s address or events with PATCH .../webhooks/:webhookId. If the secret may have leaked, New secret (or POST .../webhooks/:webhookId/rotate-secret) replaces it and shows the new one once; deliveries are signed with the new secret from then on, so update your server straight away.
Events
| Event | When | data |
|---|---|---|
| creative.completed | A video finished. | creativeId, campaignId, status, videoUrl, posterUrl, durationSeconds |
| creative.failed | A video could not be made. Its credits are returned. | creativeId, campaignId, status, failureReason |
| campaign.completed | Every video in a campaign finished. | campaignId, name, status, creatives (count by status) |
| campaign.failed | A campaign stopped. | campaignId, name, status, creatives (count by status) |
| episode.storyboard_ready | A production’s storyboard is drawn and waits for approval. | episodeId, title, studioUrl |
| episode.completed | A film is filmed, cut and scored. | episodeId, title, studioUrl, filmUrl |
| episode.failed | Filming stopped before the end. | episodeId, title, studioUrl, failureReason |
| billing.api_credits_low | An API request took your API credits below 500 (once each time they cross it), so you can buy more before calls are refused. | apiCreditBalance, threshold |
| ping | You pressed Send test in Settings. | message |
Media links in a payload work for 7 days. Fetch the creative or episode from the API with its id for a fresh one.
What arrives
POST /your/endpoint
Content-Type: application/json
User-Agent: ViewGC-Webhooks/1
ViewGC-Event: creative.completed
ViewGC-Delivery: cmw8x2k1f0003...
ViewGC-Signature: t=1761955200,v1=5f2c8e...
{
"id": "evt_3b1f0c2a9e5d4f7a8b6c1d2e3f4a5b6c",
"type": "creative.completed",
"createdAt": "2026-10-31T22:40:00.000Z",
"organizationId": "cmt28bem400001bnql3qw08ut",
"data": {
"creativeId": "cmvwwpdro000h93niov2v0z72",
"campaignId": "cmvwtrtz4000211jtabmtvdlo",
"status": "COMPLETED",
"videoUrl": "https://...",
"posterUrl": "https://...",
"durationSeconds": 14.2
}
}id is the event, the same on every retry: use it to ignore a duplicate. ViewGC-Delivery identifies this attempt.
Check the signature
ViewGC-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> under your endpoint secret. Compute it over the body exactly as received (not re-serialised JSON), compare in constant time, and refuse a t more than five minutes old.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the request body exactly as received, before JSON parsing.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
const t = Number(parts.t)
if (!t || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false // a replay
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
const given = Buffer.from(parts.v1, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts.get("t", 0)), parts.get("v1", "")
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Answering, retries and pausing
- Answer with any 2xx within 10 seconds. Do slow work after answering.
- Anything else, or no answer, is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1, 3, 6 and 12 hours: eight retries over about 22 hours. Then the delivery is marked failed.
- An endpoint whose deliveries fail 20 times in a row is paused. Settings, Webhooks shows it; turn it back on once it answers again.
- Every attempt, with the status your endpoint returned, is listed under the endpoint in Settings, and a failed delivery can be sent again from there.
- Deliveries are not guaranteed to arrive in order. Use
createdAt, and fetch the resource when order matters.
The full API, including the webhook routes, is in the interactive reference. Something unclear? Ask us.