Receive webhooks
Set up an endpoint, verify signatures, roll secrets, handle retries and test it all.
Vojo can tell your server when something happens, so you don't have to poll. Each delivery is an HTTPS POST with a JSON body, signed with a secret only you and Vojo hold.
You need the webhooks:read and webhooks:write scopes. The event reference lists every event and its payload.
Register an endpoint
curl -X POST https://api.vojo-media.com/v1/webhook-endpoints \
-H "Authorization: Bearer $VOJO_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "CMS verification",
"url": "https://hooks.your-cms.example/vojo",
"enabled_events": ["verification.ping", "verification.score"]
}'const { data: endpoint, error } = await api.POST("/v1/webhook-endpoints", {
params: { header: { "Idempotency-Key": crypto.randomUUID() } },
body: {
name: "CMS verification",
url: "https://hooks.your-cms.example/vojo",
enabled_events: ["verification.ping", "verification.score"],
},
});
if (error) throw new VojoApiError(error);
// Save endpoint.data.secret now.The response includes
data.secretonce, and no laterGETreturns it. Save it now. If you lose it, roll it.
The URL must be public https. An event type Vojo doesn't recognise returns 422.
What you'll receive
verification.pingarrives once per play, when its Passport has reported both a start and a finish.verification.scorearrives once per play, when its score is final. That's at most about 15 minutes after the play starts.
A
verification.scoresubscription made through/v1never backfills. Only plays whose score becomes final after you subscribe are sent. To load earlier plays, read them from the API.
Verify the signature
Check the signature on every delivery before you trust it. Anyone who finds your URL can send it a request.
X-Vojo-Signature: t=1761037200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdEach v1 is the hex HMAC-SHA256 of {t}.{raw body}, keyed with your secret. To verify a delivery:
- Split the header on
,and taketand everyv1. During a secret roll there are two. - Compute the HMAC over the raw request bytes. Don't parse and re-serialise the JSON first, because that changes the bytes.
- Accept the delivery if any
v1matches any secret you hold, using a constant-time compare. - Reject it if
tis more than 300 seconds from now.
Never assume a particular position or number of v1 values.
const crypto = require("node:crypto");
const express = require("express");
const verifyVojoSignature = (header, rawBody, secrets, toleranceSeconds = 300) => {
if (!header) return false;
const pairs = header.split(",").map((part) => part.trim().split("="));
const t = pairs.find(([key]) => key === "t")?.[1];
const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);
const timestamp = Number(t);
if (!t || !Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
// Accept the delivery if any v1 matches any secret you hold.
return secrets.some((secret) => {
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
});
};
const app = express();
const secrets = [process.env.VOJO_WEBHOOK_SECRET];
// express.raw keeps the exact bytes Vojo signed. Don't use express.json on this route.
app.post("/vojo", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyVojoSignature(req.get("X-Vojo-Signature"), req.body, secrets)) {
return res.status(400).end();
}
await saveForProcessing(JSON.parse(req.body.toString("utf8")));
res.status(204).end();
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
def verify_vojo_signature(header, raw_body: bytes, secrets: list[str], tolerance: int = 300) -> bool:
if not header:
return False
pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
timestamp = next((value for key, value in pairs if key == "t"), None)
signatures = [value for key, value in pairs if key == "v1"]
if timestamp is None or not timestamp.isascii() or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
# Accept the delivery if any v1 matches any secret you hold.
for secret in secrets:
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in signatures):
return True
return False
app = Flask(__name__)
secrets = [os.environ["VOJO_WEBHOOK_SECRET"]]
@app.post("/vojo")
def vojo_webhook():
raw = request.get_data() # The exact bytes Vojo signed, before any parsing.
if not verify_vojo_signature(request.headers.get("X-Vojo-Signature"), raw, secrets):
abort(400)
save_for_processing(json.loads(raw))
return "", 204package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func VerifyVojoSignature(header string, rawBody []byte, secrets []string, tolerance time.Duration) bool {
var timestamp string
var signatures [][]byte
for _, part := range strings.Split(header, ",") {
key, value, found := strings.Cut(strings.TrimSpace(part), "=")
if !found {
continue
}
switch key {
case "t":
if timestamp == "" {
timestamp = value
}
case "v1":
if signature, err := hex.DecodeString(value); err == nil {
signatures = append(signatures, signature)
}
}
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
age := math.Abs(float64(time.Now().Unix() - seconds))
if age > tolerance.Seconds() {
return false
}
// Accept the delivery if any v1 matches any secret you hold.
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, signature := range signatures {
if hmac.Equal(expected, signature) {
return true
}
}
}
return false
}Endpoints with no signature
An endpoint created or rotated before 25 September 2026 may still be on an old secret that can't sign, so its deliveries carry no X-Vojo-Signature header and signed is false on the endpoint. Roll the secret to start signing. Endpoints created now are always signed, so treat a missing header as a failed check.
Roll the secret
Rolling issues a new secret. By default the old one keeps working for 24 hours, so you can switch without dropping events.
curl -X POST "https://api.vojo-media.com/v1/webhook-endpoints/$ENDPOINT_ID/roll-secret" \
-H "Authorization: Bearer $VOJO_KEY" \
-H "Content-Type: application/json" \
-d '{ "overlap_hours": 24 }'const { data, error } = await api.POST("/v1/webhook-endpoints/{webhook_endpoint_id}/roll-secret", {
params: { path: { webhook_endpoint_id: endpointId } },
body: { overlap_hours: 24 },
});
if (error) throw new VojoApiError(error);
// Save data.data.secret now. It's returned once.overlap_hours runs from 0 to 168 and defaults to 24. During the overlap, every delivery carries two signatures over the same timestamp and body, including retries of events created before the roll:
X-Vojo-Signature: t=1761037200,v1=<new secret>,v1=<previous secret>Walkthrough
- Roll the secret and save the new value from the response. Your receiver is still running with the old secret, and its deliveries still verify because one
v1matches. - Deploy the new secret any time within the overlap. Signatures are computed when each delivery is sent, so there's no race. Either replace the old secret, or hold both for a while by adding the new one to
secrets. - Remove the old secret once the overlap has ended. Deliveries now carry only the new signature.
Ending or repeating a roll
overlap_hours: 0ends the old secret at once, and deliveries switch to the new one immediately. Use it when a secret has leaked, and deploy the new secret straight away, because deliveries will fail verification until you do. Retry them afterwards.- Rolling in the Vojo portal (Settings, then Developer, then Webhooks) has no overlap. It behaves like
overlap_hours: 0. - An endpoint with no signature yet gets no overlap either: it goes straight to signing with the new secret.
- Rolling again during an overlap keeps the original old secret, so a retried roll can't cut off the secret your receiver still holds. The secret in between stops working at once, and the overlap extends to the later of the two end times. To roll twice on purpose, wait for the overlap to end, or send
overlap_hours: 0on the second roll.
Handle retries safely
Reply with a 2xx within 20 seconds to acknowledge a delivery. Anything else counts as a failure, including a timeout or a redirect, because Vojo doesn't follow redirects.
The retry schedule
Vojo makes up to 8 attempts over about two hours. Each wait is counted from when the previous attempt started. An attempt can go out later than this, because Vojo checks for due deliveries periodically.
| Attempt | Wait from the start of the previous attempt |
|---|---|
| 1 | Sent straight away |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6 | 16 minutes |
| 7 | 32 minutes |
| 8 | 64 minutes |
After the last attempt, the delivery is marked failed.
Make your handler idempotent
A retry can arrive even after you processed the event, for example if your 2xx was lost on the way back. So the same event can reach you more than once, and you need to deduplicate.
Every event carries an id that stays the same across retries, and an attempt id that changes on each one. Deduplicate on the event id, never the attempt id.
The older events use eventId and the wrapper events use event_id, so check for both:
const handleDelivery = async (event) => {
const eventId = event.eventId ?? event.event_id;
// A unique constraint on event_id makes the duplicate check atomic, even when
// two retries arrive at once.
const { rowCount } = await db.query(
"INSERT INTO vojo_events (event_id, body) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING",
[eventId, event],
);
if (rowCount === 0) return; // Already have it.
await queue.add(eventId); // Do the real work outside the request.
};Save the event, reply 2xx, and do the slow work afterwards. If you reply before saving, a crash in between loses the event, and Vojo won't retry it.
Don't rely on order. Retries can deliver an older event after a newer one, so use the timestamps in the payload if order matters to you.
When an endpoint keeps failing
Vojo emails your organisation when an endpoint's success rate since it was last activated falls to 30% or lower. At 25% or lower, Vojo sets the endpoint's status to disabled.
A disabled endpoint receives nothing. Its queued events wait, and resume when you fix the problem and set it back to active:
curl -X PATCH "https://api.vojo-media.com/v1/webhook-endpoints/$ENDPOINT_ID" \
-H "Authorization: Bearer $VOJO_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "active" }'Inspect deliveries
GET /v1/webhook-endpoints/{webhook_endpoint_id}/deliveries lists recent deliveries and their latest attempt.
curl "https://api.vojo-media.com/v1/webhook-endpoints/$ENDPOINT_ID/deliveries" \
-H "Authorization: Bearer $VOJO_KEY"status is one of pending, success, failed, retry, disabled or skipped_filter. A skipped_filter delivery was deliberately not sent, and isn't a failure.
Retry a delivery
POST /v1/webhook-endpoints/{webhook_endpoint_id}/deliveries/{delivery_id}/retry queues one immediate redelivery. It returns 409 delivery_not_retryable if the delivery is pending, succeeded or was skipped, or if the endpoint isn't active.
Test your endpoint
Check your verification code
Sign a request yourself and send it to your local server. You don't need Vojo for this, and it's the quickest way to find out whether your raw-body handling is right.
SECRET="whsec_your_test_secret"
BODY='{"eventType":"verification.ping","eventId":"evt_test_1","attemptId":"att_1"}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i http://localhost:3000/vojo \
-H "Content-Type: application/json" \
-H "X-Vojo-Signature: t=$T,v1=$SIG" \
-d "$BODY"Then check that your code rejects what it should. Each of these must fail:
- Change one character of
BODYafter computingSIG. - Set
Tto more than 300 seconds ago, and computeSIGwith it. - Sign with a different secret.
- Send no
X-Vojo-Signatureheader.
And check that this one passes, because it's what a roll looks like. Put a signature from another secret first:
OLD=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "whsec_old" -hex | sed 's/^.* //')
curl -i http://localhost:3000/vojo \
-H "Content-Type: application/json" \
-H "X-Vojo-Signature: t=$T,v1=$OLD,v1=$SIG" \
-d "$BODY"Send the same request twice as well, and check that your handler does the work only once.
Receive real deliveries
Your URL has to be public https, so to test against Vojo from your own machine, run a tunnel that gives your local server a public URL. Register that URL on a development organisation, subscribe to verification.ping, and play a wrapped creative.
Use GET .../deliveries to see what happened to each delivery, and the retry endpoint to send a failed one again while you fix your code.
The mock server doesn't send webhooks.