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"]
  }'

The response includes data.secret once, and no later GET returns 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.ping arrives once per play, when its Passport has reported both a start and a finish.
  • verification.score arrives once per play, when its score is final. That's at most about 15 minutes after the play starts.

A verification.score subscription made through /v1 never 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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Each v1 is the hex HMAC-SHA256 of {t}.{raw body}, keyed with your secret. To verify a delivery:

  1. Split the header on , and take t and every v1. During a secret roll there are two.
  2. Compute the HMAC over the raw request bytes. Don't parse and re-serialise the JSON first, because that changes the bytes.
  3. Accept the delivery if any v1 matches any secret you hold, using a constant-time compare.
  4. Reject it if t is 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();
});

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 }'

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

  1. 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 v1 matches.
  2. 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.
  3. Remove the old secret once the overlap has ended. Deliveries now carry only the new signature.

Ending or repeating a roll

  • overlap_hours: 0 ends 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: 0 on 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:

Deduplicating with a unique constraint
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.

Send a correctly signed test delivery
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 BODY after computing SIG.
  • Set T to more than 300 seconds ago, and compute SIG with it.
  • Sign with a different secret.
  • Send no X-Vojo-Signature header.

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.