Authentication
Every operator request carries its own proof. There is no session, no bearer token and no cookie — one signature, valid for sixty seconds, accepted once. This page spells the scheme out completely, because it is the part integrators implement by hand and the part they get wrong.
The canonical string
Five lines, in this order, joined by a single \n. Sign it with HMAC-SHA256 under your operator secret and send the hex digest.
METHOD // uppercase: POST PATH // path only, no query string: /v1/payouts TIMESTAMP // unix seconds as digits, as sent in X-Timestamp IDEMPOTENCY_KEY // the header value, or an empty line when absent SHA256(BODY) // hex; sha256 of the empty string for a GET
401 on every request and no hint as to why.Two details that are easy to miss and expensive to debug:
- The query string is never signed. Sign
/v1/reports, not/v1/reports?type=DEPOSITS. The engine strips the query before rebuilding the string, so including it never matches. - The body hash covers the exact bytes you send. Serialise once, hash that string, send that string. Re-serialising between hashing and sending changes key order and whitespace, and the signature no longer describes the body.
Headers
| Header | Required | Meaning |
|---|---|---|
X-Operator-Id | Always | Your operator identifier, e.g. op_abcdefgh. |
X-Timestamp | Always | Unix seconds, as digits. Must be within 60 seconds of the engine's clock in either direction. |
X-Signature | Always | Lowercase hex HMAC-SHA256 of the canonical string. |
Idempotency-Key | On POST /v1/payouts | Part of the signature whenever it is sent. Sending it unsigned, or signing it without sending it, is a 401. |
Content-Type | On any request with a body | application/json. |
Worked example
The same request, four ways. All four produce a byte-identical signature; the conformance test in this repository asserts that against the engine's own verifier, so the documentation cannot drift away from what the server accepts.
import { PaymentsClient } from '@payments/sdk'; const payments = new PaymentsClient({ baseUrl: 'https://engine.internal', operatorId: 'op_abcdefgh', secret: process.env.PAYMENTS_SECRET }); // Signing, timestamps, retries and re-signing are handled for you. await payments.payouts.submit({ /* … */ }, 'withdrawal-0001');
import { createHash, createHmac } from 'node:crypto'; const body = JSON.stringify({ payoutId: 'pyo_wd0001a1', asset: 'BTC', amountMinor: '10000', destination: 'bc1qexample' }); const idempotencyKey = 'withdrawal-0001'; const timestamp = String(Math.floor(Date.now() / 1000)); const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex'); // The path only. A query string is never signed. const canonical = [ 'POST', '/v1/payouts', timestamp, idempotencyKey, bodyHash ].join('\n'); const signature = createHmac('sha256', process.env.PAYMENTS_SECRET) .update(canonical, 'utf8').digest('hex'); await fetch('https://engine.internal/v1/payouts', { method: 'POST', headers: { 'content-type': 'application/json', 'x-operator-id': 'op_abcdefgh', 'x-timestamp': timestamp, 'x-signature': signature, 'idempotency-key': idempotencyKey }, body // the same string that was hashed });
import hashlib, hmac, json, os, time import requests secret = os.environ["PAYMENTS_SECRET"].encode() body = json.dumps({ "payoutId": "pyo_wd0001a1", "asset": "BTC", "amountMinor": "10000", "destination": "bc1qexample", }, separators=(",", ":")) # serialise once key = "withdrawal-0001" ts = str(int(time.time())) digest = hashlib.sha256(body.encode()).hexdigest() canonical = "\n".join(["POST", "/v1/payouts", ts, key, digest]) sig = hmac.new(secret, canonical.encode(), hashlib.sha256).hexdigest() requests.post( base + "/v1/payouts", data=body, # the same bytes that were hashed headers={ "content-type": "application/json", "x-operator-id": "op_abcdefgh", "x-timestamp": ts, "x-signature": sig, "idempotency-key": key, }, )
$body = json_encode([ 'payoutId' => 'pyo_wd0001a1', 'asset' => 'BTC', 'amountMinor' => '10000', 'destination' => 'bc1qexample', ]); $key = 'withdrawal-0001'; $ts = (string) time(); $digest = hash('sha256', $body); // METHOD \n PATH \n TIMESTAMP \n IDEMPOTENCY_KEY \n SHA256(BODY) $canonical = implode("\n", ['POST', '/v1/payouts', $ts, $key, $digest]); $sig = hash_hmac('sha256', $canonical, getenv('PAYMENTS_SECRET')); $headers = [ 'content-type: application/json', 'x-operator-id: op_abcdefgh', "x-timestamp: $ts", "x-signature: $sig", "idempotency-key: $key", ];
Replay and clock skew
A signature is admitted once. The engine remembers recently seen signatures for the length of the replay window, so an intercepted request cannot be re-sent even a second later. Two consequences for your client:
- Re-sign every retry. Reusing the first attempt's headers turns a transient network failure into a permanent
401. The SDK re-signs automatically; a hand-rolled client must too. - Keep the idempotency key. Re-signing changes the signature, not the key. The signature is what stops a replay; the key is what stops a double payment. They are different mechanisms answering different questions.
The window is 60 seconds in either direction. Symmetric, so a client whose clock is ahead fails exactly as one that is behind. Run NTP; drift shows up as intermittent 401s that correlate with nothing.
Two identical requests in the same second
Timestamps are unix seconds, and the query string is not signed. Together those mean two requests to the same path inside one second produce a byte-identical canonical string — and therefore an identical signature, which the engine refuses as a replay. It catches people out in two specific shapes:
| Shape | Why it collides | What to do |
|---|---|---|
| A dashboard fanning out reads | ?asset=BTC and ?asset=ETH sign the same string, because the query is not part of it. | Issue them in turn rather than concurrently, or give each its own second. |
| An immediate retry | A retry fired within the same second re-signs to the same bytes. | Wait for the clock to tick before re-signing. The SDK does this automatically. |
| Two workers, one instruction | Genuinely the same request twice — which is what the guard is for. | Nothing. This one is the mechanism working. |
The four failures behind every 401
The engine answers the same opaque detail for all of them. That is deliberate — a specific reason would let the endpoint be used to enumerate valid operator IDs and to tune a forgery attempt — so the way to tell them apart is the requestId in the response, which appears in the engine's log next to the real cause.
| Symptom | Cause | Fix |
|---|---|---|
| 401 on every request that has a query string | The query was included in the signed path. | Sign the path only. /v1/reports, never /v1/reports?type=DEPOSITS. |
| 401 on every request, from the very first one | Four-line canonical string — the idempotency line was omitted rather than left empty. | Always five lines. An absent key is an empty line. |
| 401 on a retry that worked moments ago | The identical request was replayed inside the window. | Generate a fresh timestamp and re-sign per attempt. Keep the idempotency key. |
| 401 that comes and goes for no reason | Clock drift past 60 seconds. | Run NTP on whatever signs. Check skewSeconds in the engine log. |
Rotating a secret
Configure the operator with both secrets, deploy, move your clients to the new one, then remove the old. The verifier accepts any configured secret for an operator, so there is no window where in-flight requests fail. An unknown operator is still verified against a placeholder of the same shape rather than returning early, so response timing does not reveal which operator IDs exist.