AbsolutePayDocs

Authentication

Apps, API keys, mandatory request signing, scopes, and test vs live.

Every request authenticates with an app's API key (a bearer token) and a per-request HMAC signature. The bearer key identifies the app; the signature proves the request wasn't tampered with or replayed. Both are required.

Apps

An API key belongs to an app you create in the dashboard (Developers → Apps). Creating an app returns three secrets once — store them:

SecretHeader / usePurpose
ap_live_… / ap_test_… tokenAuthorization: Bearer …identifies the app
apisign_… signing secretrequest signature (below)signs every request
whsec_… webhook signing secretverifies our payment webhooks (only if you set a webhook URL)

New apps need admin approval

A new app starts pending review and cannot authenticate until an admin approves it (a compliance gate). Rotating an app keeps its approval. Secrets are shown once — rotate the app to get fresh ones if lost.

Request signing (required)

The SDKs sign for you

Using an SDK (Node, Python, or Go)? You never write any of this — constructing the client with your apiKey + signingSecret computes the timestamp, nonce, and signature on every request automatically. This section is for raw HTTP integrations (and the curious).

Every API-key request MUST carry three headers:

HeaderValue
X-AbsolutePay-Timestampcurrent time in epoch milliseconds
X-AbsolutePay-Noncea unique random value per request
X-AbsolutePay-Signaturehex(HMAC-SHA512(signingSecret, canonical))

The canonical string binds the method, path, time, nonce, and a hash of the body — so a signature can't be replayed or redirected to another endpoint. Each part is joined by a single newline (\n):

{METHOD}\n{path-and-query}\n{timestamp}\n{nonce}\n{sha256hex(body)}
PartRule
METHODUppercase HTTP verb — GET, POST, …
path-and-queryThe request path including the query string, exactly as sent — e.g. /v1/invoices?limit=2. Not the host.
timestampEpoch milliseconds; must be within ±5 minutes of our clock. Reuse the same value in the header.
nonceA unique random value per request (e.g. 16 random bytes, hex). Accepted once — a repeat is rejected as a replay.
sha256hex(body)Lowercase hex SHA-256 of the raw request body bytes, exactly as sent. For a request with no body, hash the empty string (e3b0c4…855).

Sign exactly what you send

Hash and sign the same path+query and body bytes you put on the wire. If you add a query param or re-serialize the JSON after signing, the signature won't match and you'll get 401.

The signature is hex(HMAC-SHA512(signingSecret, canonicalString)):

import { createHash, createHmac, randomBytes } from "node:crypto";

// `path` MUST include the query string; `body` is the exact string you send ("" if none).
function signedHeaders(method: string, path: string, body: string, signingSecret: string) {
  const ts = String(Date.now());
  const nonce = randomBytes(16).toString("hex");
  const bodyHash = createHash("sha256").update(body).digest("hex");
  const canonical = [method.toUpperCase(), path, ts, nonce, bodyHash].join("\n");
  const signature = createHmac("sha512", signingSecret).update(canonical).digest("hex");
  return { "X-AbsolutePay-Timestamp": ts, "X-AbsolutePay-Nonce": nonce, "X-AbsolutePay-Signature": signature };
}
import hashlib, hmac, time, uuid

# `path` MUST include the query string; `body` is the exact string you send ("" if none).
def signed_headers(method, path, body, signing_secret):
    ts = str(int(time.time() * 1000))
    nonce = uuid.uuid4().hex
    body_hash = hashlib.sha256(body.encode()).hexdigest()
    canonical = "\n".join([method.upper(), path, ts, nonce, body_hash])
    signature = hmac.new(signing_secret.encode(), canonical.encode(), hashlib.sha512).hexdigest()
    return {
        "X-AbsolutePay-Timestamp": ts,
        "X-AbsolutePay-Nonce": nonce,
        "X-AbsolutePay-Signature": signature,
    }
import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"crypto/sha512"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

// path MUST include the query string; body is the exact string you send ("" if none).
func signedHeaders(method, path, body, signingSecret string) map[string]string {
	ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
	nb := make([]byte, 16)
	rand.Read(nb)
	nonce := hex.EncodeToString(nb)
	bodyHash := sha256.Sum256([]byte(body))
	canonical := strings.Join([]string{strings.ToUpper(method), path, ts, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
	mac := hmac.New(sha512.New, []byte(signingSecret))
	mac.Write([]byte(canonical))
	return map[string]string{
		"X-AbsolutePay-Timestamp": ts,
		"X-AbsolutePay-Nonce":     nonce,
		"X-AbsolutePay-Signature": hex.EncodeToString(mac.Sum(nil)),
	}
}
# Emits the three signing headers as curl -H flags (bash/zsh + openssl).
# This is the sign() helper every curl sample in these docs assumes.
#   usage: curl … -H "Authorization: Bearer $APP_TOKEN" $(sign GET /v1/balances "")
sign() { # sign METHOD PATH BODY — PATH includes the query string; BODY is "" if none
  local ts=$(( $(date +%s) * 1000 ))
  local nonce=$(openssl rand -hex 16)
  local body_hash=$(printf '%s' "$3" | openssl dgst -sha256 | awk '{print $NF}')
  local canonical="$(printf '%s\n%s\n%s\n%s\n%s' "$1" "$2" "$ts" "$nonce" "$body_hash")"
  local sig=$(printf '%s' "$canonical" | openssl dgst -sha512 -hmac "$SIGNING_SECRET" | awk '{print $NF}')
  printf -- '-H X-AbsolutePay-Timestamp:%s -H X-AbsolutePay-Nonce:%s -H X-AbsolutePay-Signature:%s' \
    "$ts" "$nonce" "$sig"
}

Every endpoint in the API reference ships a ready-to-run signed curl plus Node, Python, and Go SDK samples.

Try it — in-browser signer

Paste your signing secret and a request to get the exact headers + a ready curl. It runs entirely in your browser (Web Crypto); your secret never leaves the page. Also at /signer.

Runs entirely in your browser via Web Crypto — your signing secret never leaves this page.

Scopes

Keys carry scopes that limit what they can do. A call needing a scope the key lacks is rejected with 403. Grant only what each integration needs, and issue a separate app per integration.

Each resource group has a :read scope for its reads and a :write scope for its writes — a read never requires a :write scope.

ScopeGrants
checkouts:writeCreate, update, and void checkouts (POST/PATCH/DELETE on /v1/checkouts)
checkouts:readList and read checkouts
invoices:writeCreate, update, and void invoices (POST/PATCH/DELETE on /v1/invoices)
invoices:readList and read invoices
payments:writeCreate a refund (POST /v1/refunds); issue gift cards
payments:readRead refunds (GET /v1/refunds, GET /v1/refunds/{id}) and gift cards
convert:writeQuote and execute conversions
convert:readRead settled conversion history (GET /v1/conversions)
balances:readRead balances, deposits (history, addresses, networks), and the fee preview
ledger:readRead settled history: reconciliation (payments/withdrawals) and the unified transactions ledger
payouts:writeCreate payouts, and off-ramp: quote, withdraw, register/remove a bank
payouts:readRead payout options + a payout, and off-ramp countries/banks/orders
subscriptions:read / subscriptions:writeRead / manage subscription plans and subscriptions

The exact scope each endpoint needs is shown on that endpoint in the API reference. Checkouts and invoices carry separate scope groups (checkouts:* vs invoices:*), reads use :read and writes use :write, and payments:write covers refunds and gift-card issuance (a settled payment is money out). The account:admin and app-management scopes are not grantable to tenant keys.

IP allowlist (optional)

An app can restrict which source IPs may use it (CIDRs, set on the app). Requests from other IPs are rejected with 403. Leave it empty for no restriction.

Test vs live

The token prefix selects the mode — one base URL for both:

PrefixModeFunds
ap_test_…TestSandbox only
ap_live_…LiveReal

Build against ap_test_ (signing + review apply identically); switch to ap_live_ for real funds. A key only ever acts on its own workspace.

Prefer a clean, isolated environment? Use the dedicated sandbox at https://sandbox-api.absolutepay.io (sign up at sandbox.absolutepay.io), where apps are auto-approved so you can start without waiting on review.

Errors

  • 401 unauthorized — missing/unknown key, or a missing/invalid/stale signature.
  • 403 forbidden — valid key but missing scope, or a disallowed source IP.

See Errors for the full shape.

Next

  • Quickstart — first signed call to a paid test order.
  • SDK setup — let the client do all of the above for you.
  • Webhooks — the other signature: verifying what we send you.
  • API reference — every endpoint with a ready-signed curl.

On this page