Request signing
Most endpoints require an RSA-SHA256 signature on top of your access token. You sign with the private key handed to you when your organization was provisioned — the one revealed once, that we do not keep a copy of for you. We verify with the matching public key.
This page is hand-written: request signing is not a standard OpenAPI security scheme, so the API reference cannot describe it.
The two headers​
| Header | Value |
|---|---|
signature | Base64 RSA-SHA256 signature of the message built below |
nonce | Current epoch time in milliseconds, as a string |
The nonce must land within ±5 minutes of our clock, so keep the machine
that signs in sync with NTP. It is a freshness bound, not a deduplication key —
for that, use X-Idempotency-Key.
Which endpoints are signed​
Two modes, and the mode depends on the endpoint:
| Endpoint | Mode |
|---|---|
POST /quotations | body |
POST /orders | body |
GET /quotations/{id} | query |
GET /orders/{id} | query |
GET /orders | query |
GET /me/wallets/{currency} | query |
GET /me/wallets/{currency}/transactions/{type} | query |
GET /me/routes | query |
POST /oauth2/token is the only exception, and it has to be: it is where you
get the token in the first place. Everything else is signed, so treat signing as
part of your HTTP client rather than something you add per endpoint.
Building the message — query mode​
Concatenate the path, ?, the query string, and the nonce. Nothing else, and no
separator before the nonce:
{path}?{queryString}{nonce}
For GET /me/routes?sourceCurrency=USDR with nonce 1787260853525:
/me/routes?sourceCurrency=USDR1787260853525
The ? is there even when the query string is empty.
Building the message — body mode​
Here is the part that costs people an afternoon. The message is not the raw body plus the nonce. The raw body is treated as a JSON string value and encoded again, then the nonce is appended:
JSON.stringify(rawBody) + nonce
So for the body {"quotation_id":"a1b2c3d4"} and nonce 1787260853525, the
message you sign is this — 46 characters, outer quotes included:
"{\"quotation_id\":\"a1b2c3d4\"}"1787260853525
Two consequences:
- Sign the bytes you send. Do not parse the JSON and re-serialize it before signing: key order and whitespace would change and the signature would not match. Capture the raw body string once, sign that, send that.
- Do not skip the second encoding. Signing
{"quotation_id":"a1b2c3d4"}+ nonce directly produces a signature we reject.
Worked example​
import { createSign } from "node:crypto";
const nonce = String(Date.now());
const rawBody = JSON.stringify({quotation_id: "a1b2c3d4"}); // send THIS exact string
const message = JSON.stringify(rawBody) + nonce; // body mode
// query mode: const message = `${path}?${queryString}${nonce}`;
const signer = createSign("RSA-SHA256");
signer.update(message);
signer.end();
const signature = signer.sign(privateKeyPem, "base64");
await fetch("https://api.gateway.sandbox.retorna.app/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
signature,
nonce,
},
body: rawBody,
});
In Python, pass ensure_ascii=False:
import json, time, base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
nonce = str(int(time.time() * 1000))
raw_body = json.dumps({"names": "MarÃa"}, ensure_ascii=False)
# ensure_ascii=False here too: by default Python turns "MarÃa" into
# "Mar\u00eda", which signs a different message than we verify.
message = json.dumps(raw_body, ensure_ascii=False) + nonce
signature = base64.b64encode(
private_key.sign(message.encode(), padding.PKCS1v15(), hashes.SHA256())
).decode()
That default is the second most common way to fail this: Python escapes
non-ASCII characters and we do not, so a beneficiary named MarÃa signs a
different message than the one we verify.
Signing from the reference​
You do not have to build a signer to try an endpoint. The
API reference explorer has a Private key
(PEM) field next to the bearer token: paste a PKCS#8 key there and it computes
signature and nonce for every request it sends. The key stays in that browser
tab — it is never stored, never sent, and never written into the code samples.
Use a sandbox key. Nothing about pasting a production private key into a web page is a good habit, ours included.
When it fails​
Every signature failure is the same 401 B2B_UNAUTHORIZED — wrong signature,
malformed signature, stale nonce, no key provisioned. We do not say which, on
purpose.
You can still tell it apart from a token problem: a signature 401 carries
code and category, while a token 401 carries error and message as a
string. If you see code, stop looking at your token and work down this list:
- Is the
noncein milliseconds, and is the signing machine's clock right? - Are you signing the exact bytes in the body, not a re-serialized copy?
- For body mode, did you encode the body string a second time?
- For query mode, does your query string match what you actually sent, parameter order included?
- Is the signature base64, not hex?
- Are you signing with the private key from your own provisioning?