Authentication
Every request is signed, so the secret never travels and a captured request cannot be replayed or edited.
Every server-to-server request is signed. There is no bearer token to leak into a log or a browser history: the secret never travels, and a captured request cannot be replayed or edited.
The headers
| Header | What it is |
|---|---|
X-UW-Org-Id | Your organisation. Must match the key. |
X-UW-Key-Id | Which key signed this. Public. |
X-UW-Timestamp | Unix seconds. Accepted within ±300s of our clock. |
X-UW-Nonce | Unique per request. We refuse one we have seen before. |
X-UW-Signature | v1= then the hex HMAC below. |
Idempotency-Key | Required on POST. Yours to choose. |
The timestamp and the nonce do different jobs, and you need both. A timestamp alone lets a captured request be replayed for five minutes; a nonce alone lets it be replayed forever.
The string you sign
Six parts, joined with newlines, in this order:
Sign it with HMAC-SHA256 using your secret, and send the result hex-encoded
after v1=. For a request with no body, hash the empty string.
Sign the exact bytes you send.
The signature covers the raw body, not the object it came from. If anything between you and us re-serialises the JSON — a proxy that reorders keys, a framework that pretty-prints — the signature will not match and the request is refused. That is intentional: it is also what stops anyone editing a payment in flight.
A working client
Copy this once and use it for everything. The five minutes it takes to get right is the only hard part of the integration.
import { createHmac, createHash, randomBytes } from 'node:crypto';
const BASE = 'https://api.payment.unifiwallet.com';
export async function call(method, path, body) {
const raw = body === undefined ? '' : JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(18).toString('base64url');
const bodyHash = createHash('sha256').update(raw).digest('hex');
// Order matters. Newline-joined, exactly these six parts.
const message = ['v1', method.toUpperCase(), path, timestamp, nonce, bodyHash].join('\n');
const signature = createHmac('sha256', process.env.UW_SECRET).update(message).digest('hex');
const res = await fetch(BASE + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-UW-Org-Id': process.env.UW_ORG_ID,
'X-UW-Key-Id': process.env.UW_KEY_ID,
'X-UW-Timestamp': timestamp,
'X-UW-Nonce': nonce,
'X-UW-Signature': `v1=${signature}`,
},
// Send the exact bytes you hashed. Re-serialising here would break it.
body: raw === '' ? undefined : raw,
});
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error.message}`);
return res.json();
}
import { createHmac, createHash, randomBytes } from 'node:crypto';
const BASE = 'https://api.payment.unifiwallet.com';
interface ApiError { error: { code: string; message: string; request_id: string } }
export async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const raw = body === undefined ? '' : JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(18).toString('base64url');
const bodyHash = createHash('sha256').update(raw).digest('hex');
const message = ['v1', method.toUpperCase(), path, timestamp, nonce, bodyHash].join('\n');
const signature = createHmac('sha256', process.env.UW_SECRET!).update(message).digest('hex');
const res = await fetch(BASE + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-UW-Org-Id': process.env.UW_ORG_ID!,
'X-UW-Key-Id': process.env.UW_KEY_ID!,
'X-UW-Timestamp': timestamp,
'X-UW-Nonce': nonce,
'X-UW-Signature': `v1=${signature}`,
},
body: raw === '' ? undefined : raw,
});
if (!res.ok) throw new Error((await res.json() as ApiError).error.message);
return res.json() as Promise<T>;
}
import hashlib, hmac, json, os, secrets, time
import requests
BASE = "https://api.payment.unifiwallet.com"
def call(method, path, body=None):
# separators matters: the signature covers these exact bytes.
raw = "" if body is None else json.dumps(body, separators=(",", ":"))
timestamp = str(int(time.time()))
nonce = secrets.token_urlsafe(18)
body_hash = hashlib.sha256(raw.encode()).hexdigest()
message = "\n".join(["v1", method.upper(), path, timestamp, nonce, body_hash])
signature = hmac.new(
os.environ["UW_SECRET"].encode(), message.encode(), hashlib.sha256
).hexdigest()
res = requests.request(
method,
BASE + path,
data=raw or None,
headers={
"Content-Type": "application/json",
"X-UW-Org-Id": os.environ["UW_ORG_ID"],
"X-UW-Key-Id": os.environ["UW_KEY_ID"],
"X-UW-Timestamp": timestamp,
"X-UW-Nonce": nonce,
"X-UW-Signature": "v1=" + signature,
},
)
if not res.ok:
raise RuntimeError(res.json()["error"]["message"])
return res.json()
package unifiwallet
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const base = "https://api.payment.unifiwallet.com"
func Call(method, path string, body any) (*http.Response, error) {
raw := []byte{}
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return nil, err
}
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonceBytes := make([]byte, 18)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, err
}
nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
sum := sha256.Sum256(raw)
message := strings.Join([]string{
"v1", strings.ToUpper(method), path, timestamp, nonce, hex.EncodeToString(sum[:]),
}, "\n")
mac := hmac.New(sha256.New, []byte(os.Getenv("UW_SECRET")))
mac.Write([]byte(message))
req, err := http.NewRequest(method, base+path, bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-UW-Org-Id", os.Getenv("UW_ORG_ID"))
req.Header.Set("X-UW-Key-Id", os.Getenv("UW_KEY_ID"))
req.Header.Set("X-UW-Timestamp", timestamp)
req.Header.Set("X-UW-Nonce", nonce)
req.Header.Set("X-UW-Signature", fmt.Sprintf("v1=%s", hex.EncodeToString(mac.Sum(nil))))
return http.DefaultClient.Do(req)
}
<?php
function uw_call(string $method, string $path, ?array $body = null): array
{
$base = 'https://api.payment.unifiwallet.com';
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
$bodyHash = hash('sha256', $raw);
$message = implode("\n", ['v1', strtoupper($method), $path, $timestamp, $nonce, $bodyHash]);
$signature = hash_hmac('sha256', $message, getenv('UW_SECRET'));
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-UW-Org-Id: ' . getenv('UW_ORG_ID'),
'X-UW-Key-Id: ' . getenv('UW_KEY_ID'),
'X-UW-Timestamp: ' . $timestamp,
'X-UW-Nonce: ' . $nonce,
'X-UW-Signature: v1=' . $signature,
],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
Idempotency
Every POST needs an Idempotency-Key. Use something
from your own domain — an order id works well — and send the same one if you
retry. We return the original response rather than doing the work twice.
A retry that arrives while the first is still running gets
request_in_progress. Wait and retry rather than changing the key,
or you will create a second payment.
Scopes
A key holds only what you granted it when you created it. Grant the minimum: a key that cannot refund cannot be used to refund, whoever ends up holding it.
| Scope | Allows |
|---|---|
payments:read | Read payments, refunds and subscriptions. |
payments:write | Create, cancel and expire payments and subscriptions. |
payments:capture | Capture and void authorized payments. |
refunds:write | Create and fulfil refunds. |
settlements:read | Read settlements and statements. |
webhooks:manage | Register endpoints and read deliveries. |
account:read | Read account configuration and destinations. |
account:write | Change settlement and destinations. Owner only. |
The live list, with descriptions, is at GET /v1/capabilities —
read it rather than copying this table into your code.
What a key cannot do
Two things are deliberately unreachable with an API key, however broad its scopes. Both need a person signed into the panel with their password re-entered.
- Create or rotate another key. A key that can mint a key survives its own revocation, and revoking the one that leaked would achieve nothing.
- Register a new settlement destination. Where your money lands is not something a compromised server gets to change.
Bearer tokens
A bearer alternative exists for low-risk read integrations, and it is refused outright for refunds, captures, destination changes and anything touching credentials. A bearer token is a reusable secret in a header: fine for reading a payment, not for moving money.
If a request is refused
| Code | Usually means |
|---|---|
missing_signature | One of the three signing headers is absent. |
timestamp_out_of_range | Your clock has drifted more than 300s. |
nonce_reused | You sent the same nonce twice. Generate a fresh one per request. |
invalid_signature | Almost always the body: you hashed something other than what you sent. |
insufficient_scope | The key is real but was not granted this. The message names what is missing. |
ip_not_allowed | The key has an allowlist and this address is not on it. |