OnLink
Get started

Authentication

Four headers and an HMAC-SHA256 signature over five newline-joined fields. A worked example with a signature you can check your own code against.

Every request carries four headers. There is no bearer token, no session and no OAuth flow — a client built around one will not work.

The four headers

HeaderValue
X-OnLink-KeyYour key id: pk_ followed by 24 hex characters.
X-OnLink-TimestampCurrent unix time in milliseconds, as a string.
X-OnLink-NonceUnique per request. At most 64 characters, and it may not contain :.
X-OnLink-Signaturev1= followed by the lowercase hex HMAC-SHA256 described below.

The string to sign

Five fields, joined with \n, in this exact order, with no trailing newline:

<METHOD>
<path including query string>
<unix milliseconds>
<nonce>
<lowercase hex sha256 of the raw request body>

The signature is HMAC-SHA256(your secret, that string), lowercase hex, sent as X-OnLink-Signature: v1=<hex>.

Four rules decide whether your signature matches:

  1. The method is upper-cased. POST, not post.
  2. The path includes the query string. /v1/orders/abc?expand=payment signs exactly that, not /v1/orders/abc. This is the single most common mistake.
  3. The timestamp is milliseconds, and it must be within 5 minutes of our clock in either direction.
  4. An empty body still hashes. A GET signs sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 — never an empty field, never an omitted line.

Every auth failure is the same 401

A bad signature, an expired timestamp, a replayed nonce and an unknown key all return 401 {"error":"unauthorized"} with no reason given. That is deliberate — a specific reason tells an attacker which half of a guess was right — but it means you cannot debug this from the response. Check your signing string against the worked example below instead.

Nonces and replay

Each nonce is claimed for 11 minutes. Reusing one inside that window is rejected as a replay, so generate a fresh value per request — a UUID is fine. The window is twice the timestamp tolerance plus a minute, so a request that is still within the clock window cannot outlive its own replay guard.

Worked example

These inputs produce exactly the signature below. Run your implementation against them: if you get the same 64 hex characters, your signer is correct.

secret     sk_sandbox_EXAMPLE_0123456789abcdef0123456789abcdef
method     POST
path       /v1/quotes
timestamp  1767225600000
nonce      9f8c1d4e-2b7a-4c31-8e55-6a0f2d1b3c47
body       {"side":"sell","usdtAmount":"250.000000"}

The intermediate body hash:

2ce693efd7392574c914f1c87fb6602fec408082a85e857bc61c2e0352d493f5

The string that gets signed, with real newlines between the five fields:

POST
/v1/quotes
1767225600000
9f8c1d4e-2b7a-4c31-8e55-6a0f2d1b3c47
2ce693efd7392574c914f1c87fb6602fec408082a85e857bc61c2e0352d493f5

And the signature:

v1=14bce29a5c8181adbff7d874d99c974f710d388098e4345c4eedbe28adb7e711
const crypto = require('node:crypto');

function signOnLinkRequest({ secret, method, path, body = '' }) {
  const timestamp = String(Date.now());
  const nonce = crypto.randomUUID();
  const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex');

  // Five fields, newline-joined, in this order. The method is upper-cased and
  // the path carries its query string; both are inside the signature so a
  // captured one cannot be replayed against another route.
  const signingString = [
    method.toUpperCase(),
    path,
    timestamp,
    nonce,
    bodySha256Hex,
  ].join('\n');

  const signature = crypto
    .createHmac('sha256', secret)
    .update(signingString)
    .digest('hex');

  return {
    'X-OnLink-Key': process.env.ONLINK_KEY_ID,
    'X-OnLink-Timestamp': timestamp,
    'X-OnLink-Nonce': nonce,
    'X-OnLink-Signature': `v1=${signature}`,
    'Content-Type': 'application/json',
  };
}

Sign the bytes you send

Hash the exact body string you put on the wire. If you build the signature from one object and then re-serialise it for the request, a difference in key order or spacing changes the hash and the signature will not match.

If you get a 401

In order of likelihood:

  • the query string was omitted from the signed path;
  • the timestamp was in seconds, not milliseconds;
  • the body was re-serialised between hashing and sending;
  • an empty body was signed as an empty field rather than sha256("");
  • the nonce was reused;
  • the key id and secret are from different pairs.

Verify against the worked example above before you look anywhere else — it isolates your signer from your HTTP client entirely.

Next: make your first call.

On this page