Fire a test event
POST /webhooks:test
Uptop POSTs a points.pending event to your endpoint the moment a fan’s card-spend earn is recorded — at authorization, before it settles — so your app can surface the pending reward without polling the points API. Pending points aren’t redeemable until the card transaction settles.
POINTS_WEBHOOK_URL / POINTS_WEBHOOK_SECRET). Send us the URL; the secret is shared securely, never in chat or the docs.2xx as soon as you’ve verified the signature and enqueued the work — do crediting asynchronously so a slow handler doesn’t trigger retries.The body is the same points event you’d read from the points API, wrapped in an envelope with a type and carrying your own externalUserId so you can attribute it without a lookup.
Event type. Currently always points.pending — a card-spend earn awaiting settlement.
The points-event id (stable; use it to dedupe retries).
The Uptop user id.
Your id for the user, from users:upsert.
Earn category (e.g. spend, adjustment).
Points in this event (always positive).
Present when the earn is attributable to a sponsor.
pending — these points are not redeemable until the card transaction settles.
ISO-8601 timestamp of the event.
{
"type": "points.pending",
"data": {
"id": "665f1c0c9a1e4b0012ab34cd",
"userId": "665f1b9a9a1e4b0012ab1234",
"externalUserId": "your-user-id",
"type": "spend",
"delta": 100,
"sponsorId": "665f1a4d9a1e4b0012ab0001",
"status": "pending",
"createdAt": "2026-06-15T12:00:00.000Z"
}
}Every request carries an X-Uptop-Signature header of the form t=<unix-ms>,v1=<hmac>. The v1 value is an HMAC-SHA256 of <t>.<raw-body> using your signing secret, hex-encoded.
timingSafeEqual), not ===.import { createHmac, timingSafeEqual } from "crypto";
import express from "express";
const app = express();
// Verify against the RAW request bytes — parse JSON only after the check.
app.post(
"/webhooks/uptop",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req, process.env.POINTS_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
// ...credit event.data.delta to event.data.externalUserId, then ack fast:
res.sendStatus(200);
},
);
function verify(req, secret) {
const header = req.get("X-Uptop-Signature") ?? "";
const { t, v1 } = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
if (!t || !v1) return false;
// Replay protection: reject signatures older than 5 minutes.
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${req.body}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}Send a signed sample points.pending to any URL to exercise your receiver end-to-end. It’s signed with the same secret and scheme as production (with placeholder ids), so a 2xx confirms your signature verification works before you go live.
POST /webhooks:test