Webhooks

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.

  • Configuration. Uptop sets a single endpoint URL and a signing secret per partner (POINTS_WEBHOOK_URL / POINTS_WEBHOOK_SECRET). Send us the URL; the secret is shared securely, never in chat or the docs.
  • Delivery. Fire-and-forget with up to 3 attempts. Any non-2xx response (or a timeout past 5s) is retried with a short backoff.
  • Ack fast. Respond 2xx as soon as you’ve verified the signature and enqueued the work — do crediting asynchronously so a slow handler doesn’t trigger retries.

Event payload

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.

type
string

Event type. Currently always points.pending — a card-spend earn awaiting settlement.

data.id
string

The points-event id (stable; use it to dedupe retries).

data.userId
string

The Uptop user id.

data.externalUserId
string

Your id for the user, from users:upsert.

data.type
string

Earn category (e.g. spend, adjustment).

data.delta
number

Points in this event (always positive).

data.sponsorId
string?

Present when the earn is attributable to a sponsor.

data.status
string

pending — these points are not redeemable until the card transaction settles.

data.createdAt
string

ISO-8601 timestamp of the event.

points.pending
{
  "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"
  }
}

Verifying signatures

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.

  • Compute the HMAC over the raw bytes of the body — re-serializing parsed JSON can reorder keys and break the match.
  • Compare with a constant-time check (timingSafeEqual), not ===.
  • Reject stale timestamps to blunt replays — a few minutes of tolerance is plenty.
verify.js
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);
}

Fire a test event

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.

Fire a test event

POST /webhooks:test