Webhooks
Subscribe an https endpoint (admin → Developers, or POST /v1/webhook_endpoints with the webhooks scope) and we push events as they happen: member.enrolled, ledger.entry.created, balance.updated, tier.changed, referral.qualified, redemption.created/confirmed/voided and reward.expiring_soon. Failed deliveries retry 5 times over about an hour; endpoints that keep failing are auto-disabled (re-enable from the admin, where the last 100 deliveries are logged with one-click redelivery).
Verifying signatures
Every delivery carries X-CVOS-Signature: t=<unix>,v1=<hex> — an HMAC-SHA256 of <timestamp>.<raw body> with your endpoint's whsec_… secret. Verify the MAC and reject stale timestamps (±5 minutes) to block replays. Always compute over the raw request body, before any JSON parsing.
const crypto = require("node:crypto");
function verifyCvosSignature(secret, header, rawBody, toleranceSeconds = 300) {
const t = Number(header?.match(/(?:^|,)t=(\d+)/)?.[1]);
const v1 = header?.match(/(?:^|,)v1=([a-f0-9]{64})/)?.[1];
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
app.post("/webhooks/cvos", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyCvosSignature(process.env.CVOS_WEBHOOK_SECRET,
req.header("x-cvos-signature"), req.body.toString());
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body);
// handle event.type / event.data …
res.sendStatus(200);
});<?php
function verify_cvos_signature(string $secret, ?string $header, string $rawBody, int $tolerance = 300): bool {
if (!$header || !preg_match('/(?:^|,)t=(\d+)/', $header, $tm)) return false;
if (!preg_match('/(?:^|,)v1=([a-f0-9]{64})/', $header, $vm)) return false;
$t = (int) $tm[1];
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $vm[1]);
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_CVOS_SIGNATURE'] ?? null;
if (!verify_cvos_signature(getenv('CVOS_WEBHOOK_SECRET'), $header, $rawBody)) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($rawBody, true);
// handle $event['type'] / $event['data'] …
http_response_code(200);Delivery behavior
Respond 2xx quickly (do heavy work async) — anything else counts as a failure. Deliveries can arrive out of order and, rarely, more than once: use the payload's id(evt_…) for your own dedupe. Payloads are minimal by design; fetch fresh state from the read API when you need more than the event carries.