# 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:

| Key | Used for |
|-----|----------|
| **API key** | Payments, static wallets, balance, exchange rates, and verification of payment / static-wallet webhooks |
| **Payout API key** | All `/v1/payout/*` endpoints and verification of payout webhooks |

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

> **INFO:** **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

| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Content-Type` | string | yes | Always `application/json` |
| `project` | string | yes | Your project UUID |
| `sign` | string | yes | HMAC-SHA256 signature of the request, computed with your API key |
| `User-Agent` | string | yes | Identifies 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.

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

## Implementations

<CodeSnippet name="apiSign" langs="php,js,ts,python,go" />

### 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 `""`:

<CodeSnippet name="apiSignBodyless" langs="curl,php,js,ts,python,go" />

## Full request example

<CodeSnippet name="fullRequestExample" langs="curl,php,js,ts,python,go" />

> **DANGER:** **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:

| Webhook | Key to verify with |
|---------|---------------------|
| Payment / static-wallet webhooks (`/v1/payment`, `/v1/static-wallet`) | **API key** |
| Payout webhooks (`/v1/payout`) | **Payout API key** |

> **WARNING:** **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`.

> **INFO:** **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](/docs/webhooks#verifying-the-signature)**. Retry handling and idempotency rules are in [Best practices](/docs/webhooks#best-practices).