Payments Engine

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.

canonical string
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
The idempotency line is always present. When there is no key the line is empty — it is not omitted. A four-line variant is a different string and produces a different signature, which is why a hand-rolled client that drops the line gets a 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

HeaderRequiredMeaning
X-Operator-IdAlwaysYour operator identifier, e.g. op_abcdefgh.
X-TimestampAlwaysUnix seconds, as digits. Must be within 60 seconds of the engine's clock in either direction.
X-SignatureAlwaysLowercase hex HMAC-SHA256 of the canonical string.
Idempotency-KeyOn POST /v1/payoutsPart of the signature whenever it is sent. Sending it unsigned, or signing it without sending it, is a 401.
Content-TypeOn any request with a bodyapplication/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');

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:

ShapeWhy it collidesWhat 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 retryA 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 instructionGenuinely the same request twice — which is what the guard is for.Nothing. This one is the mechanism working.
The SDK handles this for its own retries by waiting for the second to advance before re-signing. If you fan out concurrent reads of one path, sequence them yourself — a client cannot vary anything else about the request without changing what was signed.

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.

SymptomCauseFix
401 on every request that has a query stringThe 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 oneFour-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 agoThe 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 reasonClock 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.