Authentication
How to sign a request to the One API — the two credential grades, which leg you sign, and the canonical string.
Every request to the One API is authenticated. You sign one leg — your call to the gateway — and the gateway threads each product's own downstream credential on your behalf. You never build a product's signature yourself.
Which leg you sign
You sign the consumer leg (you → One API) with your own One-API credential.
Each product's own scheme (Ledger's Ledger-Hmac-*, Bankrail's X-Bankrail-*)
is threaded by the gateway during passthrough. You never construct those.
Two grades, one identity
At sign-up you receive two credentials, both resolving the same partner identity:
- A JWT on-ramp — fast to integrate, presented as
Authorization: Bearer <jwt>. It expires (default ~1 hour). - A durable HMAC root — non-expiring, returned once at registration. This is your recoverable credential and the same HMAC scheme the products speak.
When the JWT expires, re-mint a fresh one by authenticating with your HMAC root and issuing a new JWT — so an expired on-ramp is never a lockout. For sustained traffic, sign with the HMAC credential directly.
Entitlement resolves from your identity, never from the credential grade. A JWT and an HMAC credential for the same partner reach exactly the same products.
The HMAC canonical string
The One-Api-Hmac-Signature is the lowercase-hex HMAC-SHA256, under your
credential's signing secret, of a canonical string built by joining these seven
fields with a single newline, in order:
- HTTP method, uppercased
- Request path, exactly as sent (no query string)
- Sorted, percent-encoded query string (empty string if none)
One-Api-Hmac-Timestampvalue (ISO 8601 UTC)One-Api-Hmac-Noncevalue (unique per request)- Signed-headers: comma-separated lowercase header names, non-empty
- Lowercase hex SHA-256 of the raw request body bytes (empty-body hash if none)
Header value-binding
For each header named in One-Api-Hmac-Signed-Headers, append one further line
after the body hash — the lowercase header name, a colon, then the header's exact
value (for example, one-context:ctx_abc). This binds the value of each signed
header into the signature, so a bound header cannot be tampered with in transit.
Hash the body as raw bytes
Hash the request body exactly as sent. Do not re-serialize, reorder keys, or alter whitespace before hashing — the gateway compares against the bytes on the wire.
The five headers you send
One-Api-Hmac-Key-Id: <your credential key id>
One-Api-Hmac-Timestamp: <ISO 8601 UTC>
One-Api-Hmac-Nonce: <unique per request>
One-Api-Hmac-Signed-Headers: <comma-separated lowercase names, non-empty>
One-Api-Hmac-Signature: <hex HMAC-SHA256 of the canonical string>Sign a request
You don't need to hand-build the canonical string. Copy a signer below — each is
self-contained, targets the One API host one.posteering.com, and produces the
same signature the gateway's verifier expects. Set your key id and secret, and sign.
Node? Install the official signer — no copy-paste needed:
npm install @posteering/one-api-signingimport { signOneApiRequest } from '@posteering/one-api-signing';
const headers = signOneApiRequest({
method: 'POST',
path: '/api/v1/one/products/bankrail-gateway/passthrough/virtual-accounts',
keyId: process.env.ONE_API_KEY_ID,
secret: process.env.ONE_API_SECRET,
body: Buffer.from(JSON.stringify({ reference: 'order-123' }), 'utf8'),
});Other languages, or testing in Postman? Self-contained signers for curl, Python, and Go, plus a ready-to-import Postman collection, live in the signing toolkit repo. Import the collection, set your key, and your first request is signed automatically. Every signer — inline, npm, and Postman — is verified against the same test vector, so they all produce a signature the gateway accepts.
Prefer to copy a signer inline? Pick your language below.
The three things that cause a signature mismatch
- Hash the body as raw bytes, exactly as sent — don't re-serialize, reorder keys, or change whitespace after hashing.
One-Api-Hmac-Signed-Headersmust be non-empty — the verifier rejects an empty value. The signers default toone-api-hmac-timestamp,one-api-hmac-nonce.- Use the credential's signing secret — not the key id, and not the JWT on-ramp secret. Keep your clock within the replay window.
#!/usr/bin/env bash
set -euo pipefail
KEY_ID="your_key_id"; SECRET="your_signing_secret"
METHOD="POST"
PATH_ONLY="/api/v1/one/products/bankrail-gateway/passthrough/virtual-accounts"
BODY='{"reference":"order-123","holder_name":"Payer","holder_phone":"08012345678"}'
TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
NONCE=$(uuidgen | tr '[:upper:]' '[:lower:]')
SIGNED_HEADERS="one-api-hmac-timestamp,one-api-hmac-nonce"
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.*= //')
CANONICAL=$(printf '%s\n%s\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_ONLY" "" "$TS" "$NONCE" "$SIGNED_HEADERS" "$BODY_HASH")
CANONICAL=$(printf '%s\none-api-hmac-timestamp:%s\none-api-hmac-nonce:%s' "$CANONICAL" "$TS" "$NONCE")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.*= //')
curl -X "$METHOD" "https://one.posteering.com${PATH_ONLY}" \
-H "One-Api-Hmac-Key-Id: ${KEY_ID}" \
-H "One-Api-Hmac-Timestamp: ${TS}" \
-H "One-Api-Hmac-Nonce: ${NONCE}" \
-H "One-Api-Hmac-Signed-Headers: ${SIGNED_HEADERS}" \
-H "One-Api-Hmac-Signature: ${SIG}" \
-H "Content-Type: application/json" \
--data-binary "$BODY"import { createHmac, createHash, randomUUID } from 'node:crypto';
function buildSortedQuery(query) {
if (!query) return '';
const keys = Object.keys(query).sort();
if (keys.length === 0) return '';
return keys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(query[k])}`).join('&');
}
export function signOneApiRequest({
method, path, keyId, secret,
body = Buffer.alloc(0), query,
signHeaders = ['one-api-hmac-timestamp', 'one-api-hmac-nonce'],
headerValues = {},
timestamp, nonce,
}) {
method = method.toUpperCase();
const bodyBytes = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
const ts = timestamp ?? new Date().toISOString();
const nc = nonce ?? randomUUID();
const signedHeaders = signHeaders.join(',');
const sortedQuery = buildSortedQuery(query);
const bodyHash = createHash('sha256').update(bodyBytes).digest('hex');
const lines = [method, path, sortedQuery, ts, nc, signedHeaders, bodyHash];
const known = {
'one-api-hmac-timestamp': ts,
'one-api-hmac-nonce': nc,
'one-api-hmac-key-id': keyId,
'one-api-hmac-signed-headers': signedHeaders,
...headerValues,
};
for (const name of signHeaders) {
const n = String(name).trim().toLowerCase();
lines.push(`${n}:${known[n] ?? ''}`);
}
const canonical = lines.join('\n');
const signature = createHmac('sha256', secret).update(canonical).digest('hex');
return {
'One-Api-Hmac-Key-Id': keyId,
'One-Api-Hmac-Timestamp': ts,
'One-Api-Hmac-Nonce': nc,
'One-Api-Hmac-Signed-Headers': signedHeaders,
'One-Api-Hmac-Signature': signature,
};
}import hashlib, hmac, uuid
from datetime import datetime, timezone
from urllib.parse import quote
def build_sorted_query(query):
if not query:
return ""
return "&".join(f"{quote(k, safe='')}={quote(str(query[k]), safe='')}" for k in sorted(query))
def sign_one_api_request(*, method, path, key_id, secret, body=b"", query=None,
sign_headers=None, header_values=None, timestamp=None, nonce=None):
method = method.upper()
timestamp = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
nonce = nonce or str(uuid.uuid4())
sign_headers = sign_headers or ["one-api-hmac-timestamp", "one-api-hmac-nonce"]
signed_headers = ",".join(sign_headers)
body_hash = hashlib.sha256(body).hexdigest()
lines = [method, path, build_sorted_query(query), timestamp, nonce, signed_headers, body_hash]
known = {
"one-api-hmac-timestamp": timestamp,
"one-api-hmac-nonce": nonce,
"one-api-hmac-key-id": key_id,
"one-api-hmac-signed-headers": signed_headers,
**(header_values or {}),
}
for name in sign_headers:
n = name.strip().lower()
lines.append(f"{n}:{known.get(n, '')}")
canonical = "\n".join(lines)
signature = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return {
"One-Api-Hmac-Key-Id": key_id,
"One-Api-Hmac-Timestamp": timestamp,
"One-Api-Hmac-Nonce": nonce,
"One-Api-Hmac-Signed-Headers": signed_headers,
"One-Api-Hmac-Signature": signature,
}package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strings"
)
func buildSortedQuery(query map[string]string) string {
if len(query) == 0 {
return ""
}
keys := make([]string, 0, len(query))
for k := range query {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
ek := strings.ReplaceAll(url.QueryEscape(k), "+", "%20")
ev := strings.ReplaceAll(url.QueryEscape(query[k]), "+", "%20")
parts = append(parts, ek+"="+ev)
}
return strings.Join(parts, "&")
}
func SignOneApiRequest(method, path, keyID, secret string, body []byte,
query map[string]string, signHeaders []string, timestamp, nonce string) map[string]string {
method = strings.ToUpper(method)
if len(signHeaders) == 0 {
signHeaders = []string{"one-api-hmac-timestamp", "one-api-hmac-nonce"}
}
signedHeaders := strings.Join(signHeaders, ",")
sum := sha256.Sum256(body)
bodyHash := hex.EncodeToString(sum[:])
lines := []string{method, path, buildSortedQuery(query), timestamp, nonce, signedHeaders, bodyHash}
known := map[string]string{
"one-api-hmac-timestamp": timestamp,
"one-api-hmac-nonce": nonce,
"one-api-hmac-key-id": keyID,
"one-api-hmac-signed-headers": signedHeaders,
}
for _, name := range signHeaders {
n := strings.ToLower(strings.TrimSpace(name))
lines = append(lines, n+":"+known[n])
}
canonical := strings.Join(lines, "\n")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(canonical))
signature := hex.EncodeToString(mac.Sum(nil))
return map[string]string{
"One-Api-Hmac-Key-Id": keyID,
"One-Api-Hmac-Timestamp": timestamp,
"One-Api-Hmac-Nonce": nonce,
"One-Api-Hmac-Signed-Headers": signedHeaders,
"One-Api-Hmac-Signature": signature,
}
}Add this as a Pre-request Script (set keyId/secret, and set the Body to
raw/JSON using {{signedBody}} so the bytes you hash are the bytes you send):
const CryptoJS = require('crypto-js');
const keyId = 'your_key_id', secret = 'your_signing_secret';
const body = JSON.stringify({ reference: 'order-123', holder_name: 'Payer', holder_phone: '08012345678' });
pm.variables.set('signedBody', body);
const method = pm.request.method.toUpperCase();
const path = new URL(pm.request.url.toString()).pathname;
const ts = new Date().toISOString();
const nonce = (crypto.randomUUID ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(16).slice(2)));
const signedHeaders = 'one-api-hmac-timestamp,one-api-hmac-nonce';
const bodyHash = CryptoJS.SHA256(body).toString(CryptoJS.enc.Hex);
let canonical = [method, path, '', ts, nonce, signedHeaders, bodyHash].join('\n');
canonical += `\none-api-hmac-timestamp:${ts}\none-api-hmac-nonce:${nonce}`;
const signature = CryptoJS.HmacSHA256(canonical, secret).toString(CryptoJS.enc.Hex);
pm.request.headers.upsert({ key: 'One-Api-Hmac-Key-Id', value: keyId });
pm.request.headers.upsert({ key: 'One-Api-Hmac-Timestamp', value: ts });
pm.request.headers.upsert({ key: 'One-Api-Hmac-Nonce', value: nonce });
pm.request.headers.upsert({ key: 'One-Api-Hmac-Signed-Headers', value: signedHeaders });
pm.request.headers.upsert({ key: 'One-Api-Hmac-Signature', value: signature });Replay protection
The timestamp and nonce together defend against replay: the gateway rejects requests whose timestamp falls outside the configured window, and rejects a nonce it has seen within that window. Generate a fresh nonce per request.
Next
- Walk the full self-serve journey in Onboarding.
- Thread multiple customers with Contexts.