Subscribe an https endpoint (Outbound webhooks on the Developers page, 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, referral.reversed, redemption.created, redemption.confirmed, redemption.voided and reward.expiring_soon. POST /v1/webhook_endpoints/{id}/test (or Send test event) sends a webhook.test delivery whatever the endpoint subscribes to. Webhooks pause, and endpoints are kept, while the programme's plan does not include the API. A failed delivery is retried 7 more times over about 25 hours (after 1 min, 4 min, 10 min, 30 min, 2 h, 6 h and 16 h), so make your receiver idempotent on the delivery id. If an outage on our side loses a queued delivery, it is sent again about 15–20 minutes after it was due, with the same delivery id. An endpoint whose deliveries fail completely three times in a row is auto-disabled — fix the receiver, then press Re-enable in the admin, which keeps the last 100 finished deliveries per endpoint (plus every delivery still being sent or retried) and shows the latest 25, each with Redeliver. Once a delivery succeeds we keep it without the member's contact details (member.enrolled drops email), so a redelivery carries the member id only — look the member up with GET /v1/members/{id} if you need more. Redirects are never followed and each attempt times out after 10 seconds.
Verifying signatures
Every delivery is a POST with X-StickyTier-Signature: t=<unix>,v1=<hex> — an HMAC-SHA256 of <timestamp>.<raw body> with your endpoint's whsec_… secret — plus X-StickyTier-Event (the event type) and X-StickyTier-Delivery (the delivery id, the same on every retry). Receivers built before the rename can keep reading X-CVOS-Signature, X-CVOS-Event and X-CVOS-Delivery: they carry the same values. The secret is shown once, when you create the endpoint or rotate its secret; rotating stops the old secret at once. 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 verifyStickyTierSignature(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/stickytier", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyStickyTierSignature(process.env.STICKYTIER_WEBHOOK_SECRET,
req.header("x-stickytier-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_stickytier_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_STICKYTIER_SIGNATURE'] ?? null;
if (!verify_stickytier_signature(getenv('STICKYTIER_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 behaviour
Respond 2xx quickly (do heavy work async) — anything else, including a redirect, 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. Every payload has the same envelope:
{ "id": "evt_…", "type": "balance.updated", "created_at": "2026-09-22T10:31:00.000Z", "data": { … } }Payloads are minimal by design; fetch fresh state from the read API when you need more than the event carries.