Skip to main content

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​

HeaderValue
signatureBase64 RSA-SHA256 signature of the message built below
nonceCurrent 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:

EndpointMode
POST /quotationsbody
POST /ordersbody
GET /quotations/{id}query
GET /orders/{id}query
GET /ordersquery
GET /me/wallets/{currency}query
GET /me/wallets/{currency}/transactions/{type}query
GET /me/routesquery

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:

  1. Is the nonce in milliseconds, and is the signing machine's clock right?
  2. Are you signing the exact bytes in the body, not a re-serialized copy?
  3. For body mode, did you encode the body string a second time?
  4. For query mode, does your query string match what you actually sent, parameter order included?
  5. Is the signature base64, not hex?
  6. Are you signing with the private key from your own provisioning?