Signing requests

Every request carries four headers. Your API secret signs the request; the secret itself is never sent.

Header Value
Jeton-Api-Key Your key id (nbm_live_…).
Jeton-Timestamp Unix time in seconds. It must be within 300 seconds of server time.
Jeton-Nonce A random string of 16–128 printable ASCII characters, no spaces. A UUID is fine. Never reuse one with the same key.
Jeton-Signature Lower-case hex HMAC-SHA256(apiSecret, canonicalRequest).

The canonical request

Six lines, joined with \n (no trailing newline):

POST
/v2/payouts
limit=20&status=pending
1758190530
5f1c9a2e-3b7d-4c1e-9f0a-2d6b8e4c7a31
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  1. Method, upper-case.
  2. Path from /v2 on, lower-case, with no trailing slash. Path parameters are part of it: /v2/payouts/txn_…. Anything in front of /v2 in the base URL is not signed.
  3. Query. Percent-encode each key and value per RFC 3986: keep only A–Z a–z 0–9 - . _ ~, and write a space as %20, never +. Sort the pairs by key, then by value, and join them with &. A repeated key gives one pair per value. Use an empty line when there is no query.
  4. Timestamp, exactly as sent in Jeton-Timestamp.
  5. Nonce, exactly as sent in Jeton-Nonce.
  6. Body hash. Lower-case hex SHA-256 of the exact body bytes you send. For a request without a body, hash the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Test vectors

Check your signing code against these before you call the API. Both use the secret nbs_live_test_vector_secret.

A GET with a query

  • Request: GET /v2/payouts with the query status = pending and completed, limit = 20, merchantReference = INV 2026/10 (é)
  • Timestamp: 1758190530
  • Nonce: 5f1c9a2e-3b7d-4c1e-9f0a-2d6b8e4c7a31
  • Body: none

It covers a repeated key, the sort by key and then by value, and the encoding of a space, a /, parentheses (which encodeURIComponent leaves as they are) and a non-ASCII character (its UTF-8 bytes). The canonical request:

GET
/v2/payouts
limit=20&merchantReference=INV%202026%2F10%20%28%C3%A9%29&status=completed&status=pending
1758190530
5f1c9a2e-3b7d-4c1e-9f0a-2d6b8e4c7a31
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Jeton-Signature: a9dad2055a3e4ee0e98fd9fc37b81914c56b39fc94920e3edc2c7b82b35f7b6e

A POST with a body

  • Request: POST /v2/payouts, no query
  • Timestamp: 1758190530
  • Nonce: 0b9d6a54-8f3e-4c2a-b1d7-6e5f4a3c2b10
  • Body: exactly these bytes, with no trailing newline:
{"merchantReference":"INV-2026-0042","amount":{"value":"25.00","currency":"EUR"},"destination":{"type":"jeton_account","email":"[email protected]"}}

The canonical request:

POST
/v2/payouts

1758190530
0b9d6a54-8f3e-4c2a-b1d7-6e5f4a3c2b10
95f1a075aa9de68ddb385e0b79a6b2e2980fcd811d533cf96d609f13a6d08e09

Jeton-Signature: e0947a5a9f5b11b49223e03930e7f4ba7b0e05178bf5618ce775e452d07a47bc

Examples

Each example builds the query string, signs, and sends the request. Its signature function takes the timestamp and the nonce as arguments, so you can call it with the test vectors above. Every example reads your key id from JETON_API_KEY and your secret from JETON_API_SECRET.

Node.js 18+

import { createHash, createHmac, randomUUID } from 'node:crypto';

const BASE_URL = 'https://merchant-api.jeton.com';
const API_KEY = process.env.JETON_API_KEY;       // nbm_live_…
const API_SECRET = process.env.JETON_API_SECRET; // never leaves your server

// RFC 3986: encodeURIComponent leaves ! ' ( ) * as they are, so encode those as well.
const encode = (value) =>
  encodeURIComponent(value).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

const byKeyThenValue = ([ak, av], [bk, bv]) =>
  ak < bk ? -1 : ak > bk ? 1 : av < bv ? -1 : av > bv ? 1 : 0;

// { status: ['pending', 'completed'], limit: 20 } → 'limit=20&status=completed&status=pending'
export function canonicalQuery(query = {}) {
  return Object.entries(query)
    .flatMap(([key, value]) => [].concat(value).map((v) => [encode(key), encode(String(v))]))
    .sort(byKeyThenValue)
    .map(([key, value]) => `${key}=${value}`)
    .join('&');
}

export function signature(secret, method, path, queryString, timestamp, nonce, rawBody) {
  const canonical = [
    method.toUpperCase(),
    path.toLowerCase().replace(/\/+$/, '') || '/',
    queryString,
    timestamp,
    nonce,
    createHash('sha256').update(rawBody, 'utf8').digest('hex'),
  ].join('\n');
  return createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
}

export async function jeton(method, path, { query, body, idempotencyKey } = {}) {
  const queryString = canonicalQuery(query);
  const rawBody = body === undefined ? '' : JSON.stringify(body);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = randomUUID();

  const headers = {
    'Jeton-Api-Key': API_KEY,
    'Jeton-Timestamp': timestamp,
    'Jeton-Nonce': nonce,
    'Jeton-Signature': signature(API_SECRET, method, path, queryString, timestamp, nonce, rawBody),
  };
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  // Send the same encoded query and the same body bytes that were signed.
  const url = BASE_URL + path + (queryString ? `?${queryString}` : '');
  const response = await fetch(url, { method, headers, body: body === undefined ? undefined : rawBody });
  const text = await response.text();
  return { status: response.status, body: text ? JSON.parse(text) : null };
}

// Check that signing works:
console.log(await jeton('GET', '/v2/me'));

// List completed payouts:
console.log(await jeton('GET', '/v2/payouts', { query: { status: 'completed', limit: 20 } }));

Python 3.8+

import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
import uuid
from urllib.parse import quote

BASE_URL = 'https://merchant-api.jeton.com'
API_KEY = os.environ['JETON_API_KEY']        # nbm_live_…
API_SECRET = os.environ['JETON_API_SECRET']  # never leaves your server


def canonical_query(query=None):
    """{'status': ['pending', 'completed'], 'limit': 20} → 'limit=20&status=completed&status=pending'"""
    pairs = []
    for key, values in (query or {}).items():
        for value in values if isinstance(values, (list, tuple)) else [values]:
            # safe='' leaves only A-Z a-z 0-9 - . _ ~ unencoded and writes a space as %20: RFC 3986.
            pairs.append((quote(str(key), safe=''), quote(str(value), safe='')))
    return '&'.join(f'{key}={value}' for key, value in sorted(pairs))


def signature(secret, method, path, query_string, timestamp, nonce, raw_body):
    canonical = '\n'.join([
        method.upper(),
        path.lower().rstrip('/') or '/',
        query_string,
        timestamp,
        nonce,
        hashlib.sha256(raw_body).hexdigest(),
    ])
    return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()


def jeton(method, path, query=None, body=None, idempotency_key=None):
    query_string = canonical_query(query)
    raw_body = b'' if body is None else json.dumps(body).encode()
    timestamp = str(int(time.time()))
    nonce = str(uuid.uuid4())

    headers = {
        'Jeton-Api-Key': API_KEY,
        'Jeton-Timestamp': timestamp,
        'Jeton-Nonce': nonce,
        'Jeton-Signature': signature(API_SECRET, method, path, query_string, timestamp, nonce, raw_body),
    }
    if body is not None:
        headers['Content-Type'] = 'application/json'
    if idempotency_key:
        headers['Idempotency-Key'] = idempotency_key

    # Send the same encoded query and the same body bytes that were signed.
    url = BASE_URL + path + (f'?{query_string}' if query_string else '')
    request = urllib.request.Request(url, data=raw_body if body is not None else None, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request) as response:
            status, text = response.status, response.read().decode()
    except urllib.error.HTTPError as error:
        status, text = error.code, error.read().decode()
    return status, json.loads(text) if text else None


# Check that signing works:
print(jeton('GET', '/v2/me'))

# List completed payouts:
print(jeton('GET', '/v2/payouts', query={'status': 'completed', 'limit': 20}))

PHP 8

<?php

const JETON_BASE_URL = 'https://merchant-api.jeton.com';

// ['status' => ['pending', 'completed'], 'limit' => 20] → 'limit=20&status=completed&status=pending'
function jeton_canonical_query(array $query): string
{
    $pairs = [];
    foreach ($query as $key => $values) {
        foreach ((array) $values as $value) {
            // rawurlencode is RFC 3986: a space is %20, never +.
            $pairs[] = [rawurlencode((string) $key), rawurlencode((string) $value)];
        }
    }
    usort($pairs, fn (array $a, array $b): int => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]));

    return implode('&', array_map(fn (array $pair): string => $pair[0] . '=' . $pair[1], $pairs));
}

function jeton_signature(
    string $secret, string $method, string $path, string $queryString, string $timestamp, string $nonce, string $rawBody
): string {
    $canonical = implode("\n", [
        strtoupper($method),
        rtrim(strtolower($path), '/') ?: '/',
        $queryString,
        $timestamp,
        $nonce,
        hash('sha256', $rawBody),
    ]);

    return hash_hmac('sha256', $canonical, $secret);
}

function jeton(string $method, string $path, array $query = [], ?array $body = null, ?string $idempotencyKey = null): array
{
    $queryString = jeton_canonical_query($query);
    $rawBody = $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR);
    $timestamp = (string) time();
    $nonce = bin2hex(random_bytes(16));

    $headers = [
        'Jeton-Api-Key: ' . getenv('JETON_API_KEY'),
        'Jeton-Timestamp: ' . $timestamp,
        'Jeton-Nonce: ' . $nonce,
        'Jeton-Signature: ' . jeton_signature(
            getenv('JETON_API_SECRET'), $method, $path, $queryString, $timestamp, $nonce, $rawBody),
    ];
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
    }
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    // Send the same encoded query and the same body bytes that were signed.
    $curl = curl_init(JETON_BASE_URL . $path . ($queryString === '' ? '' : '?' . $queryString));
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => strtoupper($method),
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
    ]);
    if ($body !== null) {
        curl_setopt($curl, CURLOPT_POSTFIELDS, $rawBody);
    }
    $text = curl_exec($curl);
    if ($text === false) {
        throw new RuntimeException(curl_error($curl));
    }

    return [
        'status' => curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
        'body' => $text === '' ? null : json_decode($text, true),
    ];
}

// Check that signing works:
print_r(jeton('GET', '/v2/me'));

// List completed payouts:
print_r(jeton('GET', '/v2/payouts', ['status' => 'completed', 'limit' => 20]));

C# (.NET 8+)

using System.Globalization;
using System.Security.Cryptography;
using System.Text;

public sealed class JetonClient(HttpClient http, string baseUrl, string apiKey, string apiSecret)
{
    // [("status", "pending"), ("status", "completed"), ("limit", "20")] → "limit=20&status=completed&status=pending"
    public static string CanonicalQuery(IEnumerable<KeyValuePair<string, string>> query) =>
        string.Join('&', query
            // Uri.EscapeDataString is RFC 3986: a space is %20, never +.
            .Select(pair => (Key: Uri.EscapeDataString(pair.Key), Value: Uri.EscapeDataString(pair.Value)))
            .OrderBy(pair => pair.Key, StringComparer.Ordinal)
            .ThenBy(pair => pair.Value, StringComparer.Ordinal)
            .Select(pair => $"{pair.Key}={pair.Value}"));

    public static string Signature(
        string secret, string method, string path, string queryString, string timestamp, string nonce, byte[] body)
    {
        var signedPath = path.ToLowerInvariant().TrimEnd('/');
        var canonical = string.Join('\n',
            method.ToUpperInvariant(),
            signedPath.Length == 0 ? "/" : signedPath,
            queryString,
            timestamp,
            nonce,
            Hex(SHA256.HashData(body)));

        return Hex(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(canonical)));
    }

    public async Task<HttpResponseMessage> SendAsync(
        HttpMethod method, string path, IEnumerable<KeyValuePair<string, string>>? query = null,
        string? jsonBody = null, string? idempotencyKey = null, CancellationToken cancellationToken = default)
    {
        var queryString = CanonicalQuery(query ?? []);
        var body = jsonBody is null ? Array.Empty<byte>() : Encoding.UTF8.GetBytes(jsonBody);
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
        var nonce = Guid.NewGuid().ToString();

        // Send the same encoded query and the same body bytes that were signed.
        using var request = new HttpRequestMessage(
            method, baseUrl + path + (queryString.Length > 0 ? "?" + queryString : ""));
        request.Headers.Add("Jeton-Api-Key", apiKey);
        request.Headers.Add("Jeton-Timestamp", timestamp);
        request.Headers.Add("Jeton-Nonce", nonce);
        request.Headers.Add("Jeton-Signature",
            Signature(apiSecret, method.Method, path, queryString, timestamp, nonce, body));
        if (idempotencyKey is not null)
        {
            request.Headers.Add("Idempotency-Key", idempotencyKey);
        }

        if (jsonBody is not null)
        {
            request.Content = new ByteArrayContent(body);
            request.Content.Headers.ContentType = new("application/json");
        }

        return await http.SendAsync(request, cancellationToken);
    }

    private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
}
var jeton = new JetonClient(
    new HttpClient(),
    "https://merchant-api.jeton.com",
    Environment.GetEnvironmentVariable("JETON_API_KEY")!,     // nbm_live_…
    Environment.GetEnvironmentVariable("JETON_API_SECRET")!); // never leaves your server

// Check that signing works:
using var me = await jeton.SendAsync(HttpMethod.Get, "/v2/me");
Console.WriteLine($"{(int)me.StatusCode} {await me.Content.ReadAsStringAsync()}");

// List completed payouts:
using var payouts = await jeton.SendAsync(HttpMethod.Get, "/v2/payouts",
    [new("status", "completed"), new("limit", "20")]);
Console.WriteLine($"{(int)payouts.StatusCode} {await payouts.Content.ReadAsStringAsync()}");

When the answer is 401

Every authentication failure returns the same 401 with code: unauthorized. An unknown key, a bad signature, a stale timestamp, a reused nonce and an IP allowlist rejection look identical by design. Check these first:

  • Body bytes. You hashed one serialization of the body and sent another. Serialize once, then hash and send that same string.
  • Query encoding. A space was encoded as +, or !'()* were left unencoded, or the pairs were not sorted.
  • Path. The signed path does not start at /v2, is not lower-cased, or ends with a slash.
  • Clock. Your server's clock is more than 300 seconds off.
  • Nonce. A retry reused the previous nonce. Every attempt needs a new nonce, even when it reuses the Idempotency-Key.