Posteering

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:

  1. HTTP method, uppercased
  2. Request path, exactly as sent (no query string)
  3. Sorted, percent-encoded query string (empty string if none)
  4. One-Api-Hmac-Timestamp value (ISO 8601 UTC)
  5. One-Api-Hmac-Nonce value (unique per request)
  6. Signed-headers: comma-separated lowercase header names, non-empty
  7. 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-signing
import { 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

  1. Hash the body as raw bytes, exactly as sent — don't re-serialize, reorder keys, or change whitespace after hashing.
  2. One-Api-Hmac-Signed-Headers must be non-empty — the verifier rejects an empty value. The signers default to one-api-hmac-timestamp,one-api-hmac-nonce.
  3. 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.

On this page