> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pay.aptahq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Sign every AptaPay request with HMAC-SHA256 over a ten-field canonical string.

Every request to AptaPay is signed. There is no login call and no token
endpoint — you do not trade credentials for a session. You compute a
signature per request, and it is valid for seconds.

<Info>
  If you are debugging a stubborn 401, jump straight to
  [401 triage](#401-triage). The server can only ever answer `401` for any
  signing failure, so the cause is never in the response body.
</Info>

## Your credentials

A tenant credential set is four values, issued together, per mode:

| Value            | Secret? | What it is                                           |
| ---------------- | ------- | ---------------------------------------------------- |
| `app_id`         | No      | Names your application.                              |
| `key_id`         | No      | Names which signing-secret generation you are using. |
| `api_key`        | **Yes** | Proves who is calling. Looks like `ak_live_…`.       |
| `signing_secret` | **Yes** | 64 hex characters. Signs requests to us.             |

You are also issued an **outbound signing secret**, which is a separate
value used only to verify webhooks we send you. See
[Webhooks](/concepts/webhooks).

<Warning>
  Live and test are completely separate credential sets. A tenant gets two.
  Never put either set in source control — store them in your secret manager.
</Warning>

## The four headers

Every request except `/v1/webhooks/*` carries these:

```http theme={null}
X-AptaPay-Key:       <app_id>.<key_id>.<api_key>
X-AptaPay-Timestamp: <unix seconds>
X-AptaPay-Nonce:     <random string, 16-64 chars, [A-Za-z0-9_-]>
X-AptaPay-Signature: v1=<hex HMAC-SHA256, 64 chars>
```

`X-AptaPay-Key` packs three values with `.` as the separator. Only the third
is secret.

## The canonical string

The signature is an HMAC-SHA256, hex-encoded, over ten fields joined with
newlines, in exactly this order:

```text theme={null}
v1
{APP_ID}
{KEY_ID}
{HOST}
{METHOD}
{PATH}
{CANONICAL_QUERY}
{TIMESTAMP}
{NONCE}
{SHA256_HEX(RAW_BODY)}
```

Field by field:

* **`v1`** — the scheme version, literally the string `v1`.
* **`APP_ID`**, **`KEY_ID`** — the same values you sent in `X-AptaPay-Key`.
* **`HOST`** — the `Host` header, lowercased, with no port unless your base
  URL includes one.
* **`METHOD`** — uppercase (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`).
* **`PATH`** — the URL path only. No query string. Use the *undecoded* path
  exactly as it goes on the wire.
* **`CANONICAL_QUERY`** — every query parameter, RFC3986-encoded, sorted by
  name then value, joined `k=v&k=v`. Empty string when there is no query.
* **`TIMESTAMP`** — Unix seconds as a 10-digit string, matching the header.
* **`NONCE`** — matching the header. Fresh and unpredictable per request.
* **`SHA256_HEX(RAW_BODY)`** — SHA-256 hex digest of the *exact raw bytes* of
  the body. For a GET or DELETE with no body this is the well-known
  empty-string digest:
  `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.

<Warning>
  A GET whose query differs from what you signed will not verify. This is
  deliberate: a captured read signature must not be replayable against a
  different query, so `?limit=1` and `?limit=100000` do not share a signature.
</Warning>

## Reference implementation

This is the same primitive the gateway uses internally, so signing and
verification are provably one scheme rather than two implementations that
happen to agree today. It needs nothing but Node's built-in `crypto`.

```typescript signing.ts theme={null}
import { createHash, createHmac, randomUUID } from "node:crypto";

const APP_ID = process.env.APTAPAY_APP_ID!;
const KEY_ID = process.env.APTAPAY_KEY_ID!;
const API_KEY = process.env.APTAPAY_API_KEY!;
const SIGNING_SECRET = process.env.APTAPAY_SIGNING_SECRET!;
const BASE_URL = "https://api.pay.aptahq.com";

function rfc3986(s: string): string {
  return encodeURIComponent(s).replace(
    /[!'()*]/g,
    (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
  );
}

/** Sorted, RFC3986-encoded query string. "" when there are no params. */
function canonicalQuery(params: URLSearchParams): string {
  const pairs = [...params.entries()].map(
    ([k, v]) => [rfc3986(k), rfc3986(v)] as const,
  );
  pairs.sort(([ak, av], [bk, bv]) =>
    ak !== bk ? (ak < bk ? -1 : 1) : av < bv ? -1 : av > bv ? 1 : 0,
  );
  return pairs.map(([k, v]) => `${k}=${v}`).join("&");
}

/** Sign one request. Returns the four headers to attach. */
export function signRequest(
  method: string,
  path: string,
  query: URLSearchParams,
  body: Buffer,
) {
  const host = new URL(BASE_URL).host.toLowerCase();
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = randomUUID().replace(/-/g, "");
  const bodyHash = createHash("sha256").update(body).digest("hex");

  const canonical = [
    "v1",
    APP_ID,
    KEY_ID,
    host,
    method.toUpperCase(),
    path,
    canonicalQuery(query),
    timestamp,
    nonce,
    bodyHash,
  ].join("\n");

  const signature = createHmac("sha256", SIGNING_SECRET)
    .update(canonical)
    .digest("hex");

  return {
    "X-AptaPay-Key": `${APP_ID}.${KEY_ID}.${API_KEY}`,
    "X-AptaPay-Timestamp": timestamp,
    "X-AptaPay-Nonce": nonce,
    "X-AptaPay-Signature": `v1=${signature}`,
  };
}
```

Using it for a charge:

```typescript charge.ts theme={null}
const body = Buffer.from(
  JSON.stringify({
    amount_minor: 500000,
    currency: "UGX",
    country: "UG",
    method: "momo",
    payer: { msisdn: "+256700000000" },
    metadata: { order_id: "ord_123" },
  }),
);

const res = await fetch("https://api.pay.aptahq.com/v1/collections", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),
    ...signRequest("POST", "/v1/collections", new URLSearchParams(), body),
  },
  body,
});
```

<Note>
  Two secrets, never one. The secret that signs your outbound requests to
  AptaPay and the secret AptaPay uses to sign callbacks to you are separate
  values, so a leak in one direction does not compromise the other.
</Note>

## Timestamp window

Your timestamp must be within **−120s to +30s** of AptaPay's clock. The
window is intentionally asymmetric: there is no legitimate reason for a
client to sign meaningfully into the future.

<Warning>
  A skewed clock reads exactly like a bad secret. If signing worked yesterday
  and fails today on an unchanged integration, check NTP before you check
  credentials.
</Warning>

## Nonces and replay

A nonce cannot be reused inside the freshness window. Use a fresh,
unpredictable value per request — a UUID with dashes stripped works well.
Replaying a captured raw request is rejected.

The nonce is claimed only *after* the HMAC verifies, so unauthenticated
traffic cannot force writes on our side.

## Body size

Signed request bodies are capped at **256 KB**. Anything larger is rejected
with `body_too_large` before the HMAC is even computed.

## 401 triage

Every signing failure returns `401` with no detail, by design. Work down this
list in order:

<Steps>
  <Step title="Check your clock">
    Run `date -u` on the calling machine and compare to real UTC. Outside
    −120s/+30s, nothing else matters.
  </Step>

  <Step title="Confirm you signed the raw bytes">
    The body hash must cover the exact bytes you transmit. Serializing the
    object twice — once to sign, once to send — produces different bytes if
    key order or whitespace differs. Serialize once into a `Buffer`, sign
    that buffer, send that buffer.
  </Step>

  <Step title="Check the host field">
    Lowercased, and the port is included only when your base URL has one.
    Signing `api.pay.aptahq.com:443` will not verify.

    **Sign the host you actually dial.** The host is part of the canonical
    string, so it must match your base URL exactly. A mismatch produces a
    `401 invalid_signature` that looks like a bad key and is not one.
  </Step>

  <Step title="Check the query string">
    Sorted by name then value, RFC3986-encoded, and empty string when there
    is no query — not omitted, not a `?`.
  </Step>

  <Step title="Confirm the nonce is fresh">
    A duplicated nonce inside the window is a replay rejection, which also
    surfaces as 401.
  </Step>

  <Step title="Confirm you are using the right secret for the mode">
    Live and test sets are separate. A test key against a live base URL fails.
  </Step>
</Steps>

<Tip>
  The fastest way to find a mismatch is the Postman collection, which signs
  every request for you. Set `debug_signing` to `true` and it prints the
  canonical string to the Postman console, so you can diff it against yours.
  See [Testing](/guides/testing).
</Tip>

## Rate limits

Requests are rate limited per tenant per minute. Exceeding the limit returns
`rate_limited`, which is **retriable** — back off and retry with the same
idempotency key. See [Errors](/concepts/errors).
