UnifiWallet Payments

Webhooks

We tell your server what happened. Verify the signature against the raw body, answer immediately, and deduplicate on the event id.

We tell your server what happened rather than making you poll for it. Every delivery is signed, and an endpoint that does not verify the signature is just a URL anybody on the internet can post to.

Registering an endpoint, rotating its secret, and replaying an event are on the API reference.

What arrives

HeaderWhat it is
X-UW-Event-IdStable per event. Use it to deduplicate.
X-UW-Event-TypeFor example payment.completed.
X-UW-TimestampUnix seconds, and part of what is signed.
X-UW-Signaturet=…,v1=… — see below.
X-UW-Delivery-Attempt1 on the first try, higher on a retry.
X-UW-Api-VersionThe shape of the body.

Verifying

The signed payload is the timestamp, a full stop, and the raw body:

<timestamp>.<raw request body>

HMAC-SHA256 it with your endpoint secret and compare, in constant time, against each v1= value in the header. There are two while you rotate a secret, so accept either — that is what lets you change it without a moment where deliveries fail.

Verify before you parse.

The signature covers the exact bytes we sent. Most frameworks parse JSON before your handler runs and hand you an object; re-serialising that object produces different bytes and the signature will never match. Take the raw body first — the samples below show how in each framework.

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// The signature covers the RAW body. Parse it after verifying, never before.
app.post('/webhooks/unifiwallet',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('X-UW-Signature') || '';
    const timestamp = req.get('X-UW-Timestamp') || '';
    const raw = req.body.toString('utf8');

    // Reject anything old enough to be a replay.
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    const expected = createHmac('sha256', process.env.UW_WEBHOOK_SECRET)
      .update(`${timestamp}.${raw}`)
      .digest('hex');

    // A rotation sends two v1 values. Accept either.
    const presented = header.split(',')
      .filter((p) => p.startsWith('v1='))
      .map((p) => p.slice(3));

    const ok = presented.some((sig) => {
      const a = Buffer.from(sig, 'hex');
      const b = Buffer.from(expected, 'hex');
      return a.length === b.length && timingSafeEqual(a, b);
    });

    if (!ok) return res.status(400).send('bad signature');

    const event = JSON.parse(raw);

    // Answer first, work afterwards. We retry on anything but a 2xx, and a
    // slow handler looks identical to a broken one.
    res.status(200).end();
    handle(event).catch(console.error);
  });

Answer first, work afterwards

Return 2xx as soon as you have verified and stored the event. We treat anything else, and anything slower than ten seconds, as a failure and retry it — so a handler that ships an order before responding gets the order shipped twice.

Retries back off over roughly three days. An endpoint failing for 72 hours is disabled and every owner is emailed; nothing is thrown away, and you can replay what was missed once it is fixed.

Deduplicate on the event id

Delivery is at-least-once. A network timeout on our side looks exactly like a refusal, so an event you have already handled can arrive again. Record X-UW-Event-Id somewhere durable and ignore one you have seen.

In memory is not durable. A restart forgets every id, and our retry queue will happily replay a day of events into a server that now believes it has seen none of them.

The two events worth alerting on

Most events are bookkeeping. These two are not, and they should reach a human rather than a log:

EventWhy it matters
payment.settlement_rejectedA transaction carried one of your payment ids but did not match its terms — a different address, a different token, or too little. Nothing was credited. It means somebody tried.
payment.duplicate_settlementMoney arrived for a payment that had already settled. It is yours to return, and nobody will chase you for it.

See every event type for the full list.

Ordering

Events are not guaranteed to arrive in order. A retried payment.settling can land after the payment.completed that followed it. Treat each event as a statement about a payment and re-read the payment if the order matters, rather than assuming a sequence.

Testing an endpoint

The panel sends a webhook.test event to any endpoint on request, and shows every delivery attempt with the response you returned. That is the fastest way to find out that your framework parsed the body before you got it.