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
| Header | What it is |
|---|---|
X-UW-Event-Id | Stable per event. Use it to deduplicate. |
X-UW-Event-Type | For example payment.completed. |
X-UW-Timestamp | Unix seconds, and part of what is signed. |
X-UW-Signature | t=…,v1=… — see below. |
X-UW-Delivery-Attempt | 1 on the first try, higher on a retry. |
X-UW-Api-Version | The shape of the body. |
Verifying
The signed payload is the timestamp, a full stop, and the raw 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);
});
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/unifiwallet")
def webhook():
# The signature covers the RAW body. Do not let a framework parse it first.
raw = request.get_data(as_text=True)
timestamp = request.headers.get("X-UW-Timestamp", "")
header = request.headers.get("X-UW-Signature", "")
if abs(time.time() - int(timestamp or 0)) > 300:
return "stale", 400
expected = hmac.new(
os.environ["UW_WEBHOOK_SECRET"].encode(),
f"{timestamp}.{raw}".encode(),
hashlib.sha256,
).hexdigest()
# A rotation sends two v1 values. Accept either.
presented = [p[3:] for p in header.split(",") if p.startswith("v1=")]
if not any(hmac.compare_digest(sig, expected) for sig in presented):
return "bad signature", 400
event = request.get_json(force=True)
# Answer immediately; do the work out of band.
enqueue(event)
return "", 200
func Webhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
timestamp := r.Header.Get("X-UW-Timestamp")
seconds, _ := strconv.ParseInt(timestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-seconds)) > 300 {
w.WriteHeader(http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("UW_WEBHOOK_SECRET")))
mac.Write([]byte(timestamp + "." + string(raw)))
expected := mac.Sum(nil)
// A rotation sends two v1 values. Accept either.
ok := false
for _, part := range strings.Split(r.Header.Get("X-UW-Signature"), ",") {
if !strings.HasPrefix(part, "v1=") {
continue
}
if presented, err := hex.DecodeString(part[3:]); err == nil {
if hmac.Equal(presented, expected) {
ok = true
}
}
}
if !ok {
w.WriteHeader(http.StatusBadRequest)
return
}
var event Event
json.Unmarshal(raw, &event)
w.WriteHeader(http.StatusOK) // answer first
go handle(event)
}
<?php
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_UW_TIMESTAMP'] ?? '';
$header = $_SERVER['HTTP_X_UW_SIGNATURE'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('stale');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, getenv('UW_WEBHOOK_SECRET'));
// A rotation sends two v1 values. Accept either.
$ok = false;
foreach (explode(',', $header) as $part) {
if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) {
$ok = true;
}
}
if (!$ok) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true);
http_response_code(200); // answer first
handle($event);
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:
| Event | Why it matters |
|---|---|
payment.settlement_rejected | A 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_settlement | Money 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.