Sign in
Introduction/Authentication

Authentication & Request Signing

Sign API requests with HMAC-SHA256 using your project UUID and API key.

Every API request (except incoming webhooks) must carry your project UUID and a request signature. The signature proves the request came from you and that nobody changed it on the way.

API keys

2328.io uses two keys that share the same signing algorithm but cover different endpoints:

KeyUsed for
API keyPayments, static wallets, balance, exchange rates, and verification of payment / static-wallet webhooks
Payout API keyAll /v1/payout/* endpoints and verification of payout webhooks

Both keys live in your project settings on 2328.io. Examples below say "API key" generically — substitute the right one for the endpoint you're calling.

Never mix the two keys: signing a payout request with the regular API key (or a payment request with the payout key) returns a signature error.

Required headers

HeaderTypeRequiredDescription
Content-TypestringyesAlways application/json
projectstringyesYour project UUID
signstringyesHMAC-SHA256 signature of the request, computed with your API key
User-AgentstringyesIdentifies your application (e.g. MyShop/1.4 (+https://myshop.example)). Requests without a User-Agent may be blocked.

How the signature works

Think of the signature as a fingerprint of the request body. It is built by:

  1. Serializing the body to JSON (compact — no extra whitespace).
  2. Base64-encoding that JSON. This step normalises the input across languages — once it's plain ASCII, every language produces the same bytes for HMAC.
  3. Computing HMAC-SHA256 of the Base64 string using your API key, then converting the result to lowercase hex.

For GET and other request types without a body, sign an empty string instead of the JSON.

The empty-string signature is constant for a given API key. You can cache it if you make many GET calls.

Implementations

PHP
<?php
function apiSign(array $data, string $apiKey): string {
    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $base64 = base64_encode($json);
    return hash_hmac('sha256', $base64, $apiKey);
}

Bodyless requests (GET)

For empty-body requests (e.g. GET /v1/payout/status/{uuid}), sign an empty string. Since base64_encode('') is also empty, the HMAC input is just "":

Shell
SIGN=$(printf '' | openssl dgst -sha256 -hmac "$API_KEY" -hex | awk '{print $NF}')

Full request example

Shell
curl -X POST https://api.2328.io/api/v1/payment \
  -H "Content-Type: application/json" \
  -H "User-Agent: MyShop/1.0 (+https://myshop.example)" \
  -H "project: YOUR_PROJECT_UUID" \
  -H "sign: YOUR_HMAC_SIGNATURE" \
  -d '{"amount":"100.00","currency":"USD","order_id":"ORDER-123"}'

Never expose your API key in client-side code. Sign requests on your backend. A leaked API key gives anyone full access to your merchant account.

Verifying webhook signatures

When 2328.io sends you a webhook, the same algorithm runs in reverse:

  1. Pull the sign field out of the payload.
  2. JSON-encode the remaining fields (compact, no whitespace).
  3. Base64-encode that string.
  4. Compute HMAC-SHA256 with the appropriate key.
  5. Compare it with the received sign using a constant-time comparison (hash_equals, crypto.timingSafeEqual, hmac.compare_digest, subtle.ConstantTimeCompare, OpenSSL.fixed_length_secure_compare).

The signing key depends on the webhook source:

WebhookKey to verify with
Payment / static-wallet webhooks (/v1/payment, /v1/static-wallet)API key
Payout webhooks (/v1/payout)Payout API key

Common verification pitfalls. Your JSON encoder must produce the exact same bytes the sender produced — otherwise the Base64 differs and the signature won't match.

  • Go: use json.NewEncoder with SetEscapeHTML(false). The default json.Marshal escapes <, >, & to < and breaks the signature.
  • Python: pass ensure_ascii=False to json.dumps. Without it, non-ASCII (Cyrillic, Chinese, …) is escaped to \uXXXX.
  • Compact JSON: no whitespace between fields (separators=(",", ":") in Python).
  • Field order (Go): a plain map[string]any randomises keys on re-encode. Use json.RawMessage, an ordered struct, or strip sign from the raw bytes.

If verification keeps failing, run apiSign on the payload yourself — it must produce the same hex string as the received sign.

A valid signature does not prevent replays. It only proves the webhook came from 2328.io — it doesn't stop an attacker from re-posting a captured webhook later. Always check idempotency by uuid (or txid for static wallets) before crediting funds. Reject with HTTP 401 if the signature is missing or wrong.

Full code examples live on Webhook Notifications. Retry handling and idempotency rules are in Best practices.