Fire a test event
POST /webhooks:test
Uptop POSTs signed events to your endpoint as fans transact: points.pending the moment a card-spend earn is recorded — at authorization, before it settles — and transaction.created with the raw card-transaction data for every approved transaction we can attribute to one of your users, whether or not it earned points. Both share the endpoint, secret, and signature scheme — switch on type.
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.
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"
}
}Fires for every approved card transaction attributable to one of your users — including transactions that award no points. Earning transactions also emit points.pending; join the two with pointsEventId. Fields the card network didn’t provide are explicit null, so the key set is stable.
transaction.created — an approved card transaction.
The card-event id (stable; use it to dedupe retries).
The Uptop user id.
Your id for the user, from users:upsert.
Stable card-transaction id — the same tap keeps this id across authorization events.
Authorized amount in cents.
ISO currency code, e.g. USD.
Raw card-network merchant descriptor, exactly as received (e.g. TST* YUNOMI HANDROLL).
Merchant category code.
Card-network merchant identifier (the acceptor ID), exactly as received. Not stable across terminals or processors, so key merchant logic on sponsorId or descriptor instead.
Merchant city, when the network provides it.
Merchant state/region, when the network provides it.
The matched active sponsor, or null when no active sponsor matched.
Points created by this transaction (0 when nothing was earned).
Id of the matching points.pending event, or null when no points were created.
ISO-8601 timestamp of the event.
{
"type": "transaction.created",
"data": {
"id": "evt_9f2c81d6b4a34",
"userId": "665f1b9a9a1e4b0012ab1234",
"externalUserId": "your-user-id",
"cardTransactionId": "ctxn_7a1b2c3d4e5f6",
"amountCents": 1599,
"currency": "USD",
"descriptor": "TST* YUNOMI HANDROLL",
"mcc": "5812",
"merchantId": "445123456789",
"city": "Las Vegas",
"state": "NV",
"sponsorId": null,
"pointsAwarded": 15,
"pointsEventId": "665f1c0c9a1e4b0012ab34cd",
"createdAt": "2026-08-04T17: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 — the scheme is identical for transaction.created.
POST /webhooks:test