<!-- Source: https://docs.infraio.xyz/en/api-reference/authentication -->
<!-- Last updated: 2026-10-04 -->

# Authentication

InfraIO Pay has **two API surfaces** with different auth models. Pick
the one that matches who's calling:

| Surface | Path prefix | Audience | Auth |
| --- | --- | --- | --- |
| **Merchant B2B** | `/b2b/v1/*` | Your server | HMAC-SHA256 request signing |
| **Dashboard** | Used by the merchant dashboard | Browser sessions for the merchant dashboard | Bearer JWT |

This page covers the **B2B** surface, the one you call from your
server with an API key pair. The dashboard surface is used by the InfraIO Pay
merchant dashboard and isn't a public integration surface.

Always send the full path including the `/b2b` prefix, and sign that
same path (see below).

## Endpoints

| Environment | Base URL |
| --- | --- |
| Test | `https://api-dev.infraio.xyz` |
| Live | `https://api.infraio.xyz` |

Same URL pattern — environment is controlled by the **key prefix**
(`pk_test_…` vs `pk_live_…`), not the URL.

## Key pair

You get two values from the merchant dashboard (**Developers → API
keys → + Add key**):

- **Publishable key** (`pk_test_…` or `pk_live_…`) — identifies your
  account. Sent as `X-Client-ID`. Safe to embed in your browser
  bundle (the SDK already does).
- **Secret key** (`sk_test_…` or `sk_live_…`) — the HMAC signing key.
  Server-only. Treat it like a database password.

> **Important:**
>
> If a secret key ever lands in a browser bundle, git repo, log line,
> or shared chat — **revoke it immediately** from the dashboard.
> Revocation is instant, with no overlap window. Issue a new key and
> redeploy.

## Signing a request

Every call to `/b2b/v1/*` carries three headers:

```http
X-Client-ID:  pk_live_70d0becbd7a53047b0dfe88a960d10ad
X-Timestamp:  1715990400
X-Signature:  9a8b7c6d…             (hex HMAC-SHA256)
```

The signature is computed over a canonical string:

```
METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + BODY
```

- `METHOD` — uppercase HTTP verb (`POST`, `GET`, …).
- `PATH` — request path **including the `/b2b` prefix**, without the
  host and **without the query string** (e.g.
  `/b2b/v1/checkout-sessions/quick`). The prefix must be
  present. Query parameters are **not** signed — for
  a `GET …?cursor=…&limit=20`, sign only the path, not the `?…` part.
- `TIMESTAMP` — unix seconds, as decimal string (e.g. `"1715990400"`),
  matching `X-Timestamp` exactly.
- `BODY` — raw request body bytes. Empty string for `GET`/`DELETE`.

Sign with HMAC-SHA256 keyed by the **secret** key, output **hex**:

**Node / TS**

```ts
import { createHmac } from "node:crypto";

function sign({ method, path, body, secret }: {
  method: string; path: string; body: string; secret: string;
}) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const input = [method.toUpperCase(), path, timestamp, body].join("\n");
  const signature = createHmac("sha256", secret).update(input).digest("hex");
  return { timestamp, signature };
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "time"
)

func Sign(method, path, body, secret string) (timestamp, signature string) {
    timestamp = strconv.FormatInt(time.Now().Unix(), 10)
    input := method + "\n" + path + "\n" + timestamp + "\n" + body
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(input))
    return timestamp, hex.EncodeToString(mac.Sum(nil))
}
```

**Python**

```python
import hmac, hashlib, time

def sign(method: str, path: str, body: str, secret: str):
    timestamp = str(int(time.time()))
    input_ = f"{method}\n{path}\n{timestamp}\n{body}"
    signature = hmac.new(
        secret.encode(), input_.encode(), hashlib.sha256
    ).hexdigest()
    return timestamp, signature
```

## Why HMAC, not Bearer?

A bare Bearer-token API ships your only secret over the wire on every
request. Anyone who captures one TLS-terminated proxy log gets the
keys to your account. HMAC signing means the secret never travels —
only its derived signature, which is single-use (bound to that exact
request + that exact minute).

The trade-off: you compute a signature for every call. There is no server
SDK yet, but the helper above is about 15 lines per language.

## Timestamp tolerance

The tolerance is **±5 minutes** (300 seconds). A request outside that
window is rejected with `401 invalid_signature`. Two
implications:

1. **Sync your server clock** with NTP. A long-running cron with a
   drifted clock will fail intermittently.
2. **Don't pre-compute and queue signatures.** If a request sits in a
   retry queue for >5 min, its signature expires.

## Key scopes

Secret keys carry one or more of these scope bundles:

| Scope | Intended use |
| --- | --- |
| `read` | List/read orders, sessions, refunds |
| `write_order` | Create checkout sessions, orders |
| `write_refund` | Issue refunds, mint refund-request tokens |
| `webhook_manage` | Create/update/delete webhook endpoints |

The dashboard issues a "full access" key by default (all four
scopes). You can mint a restricted-scope key from **Developers →
API keys → + Add key** and tick only the scopes the integration
needs.

> **Warning:**
>
> **Scopes are not enforced yet.** Scopes are recorded on the key and
> shown in the dashboard, but any valid `sk_…` key can call any
> `/b2b/v1/*` endpoint for your merchant. Don't rely on scopes as a
> security boundary. Rotate or revoke keys to restrict access.

## Failed verification

If the signature, `X-Client-ID`, or timestamp is invalid, the request is
rejected with **401 `INVALID_SIGNATURE`** before it reaches the API. Only
`/b2b/v1/*` requests are signed this way. Webhooks use a separate scheme
(see below).

## What's next

- [Errors](https://docs.infraio.xyz/en/api-reference/errors) — response shape on 4xx/5xx.
- [Security → API keys](https://docs.infraio.xyz/en/security/api-keys) — rotation, revocation,
  what to do if a secret leaks.
- [Webhooks → Signature verification](https://docs.infraio.xyz/en/webhooks/signature-verification)
  — uses a *different* HMAC scheme (header `X-Signature: sha256=…`,
  signs `X-Timestamp + "." + raw_body`, plus an optional
  `X-Signature-Prev` during the 24-hour rotation grace window).
  Don't mix the schemes up — they share the hash algorithm but the
  signed bytes and the secret family (`whsec_…` vs `sk_…`) are
  different.
