> ## Documentation Index
> Fetch the complete documentation index at: https://partners.ladder.africa/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Every request carries an HMAC-SHA512 signature over its own timestamp, method, path and body. Your secret never travels.

## Why requests are signed

* **Your secret never leaves your servers.** A leaked request or log line cannot be used to make other requests.
* **Requests cannot be altered in transit.** Changing the amount, the investment or the path invalidates the signature.
* **Captured requests expire.** The timestamp must be within 5 minutes of server time, and `Idempotency-Key` makes a replayed POST harmless.

A signature is valid for one request only, so sign each request just before sending it. The API playground on this site cannot sign requests; test with your own code.

## Headers

```
X-Ladder-Key:        pk_test_…  or  pk_live_…
X-Ladder-Timestamp:  unix seconds, as a string
X-Ladder-Signature:  lower-case hex, 128 characters
Idempotency-Key:     unique per logical operation      (every POST)
Content-Type:        application/json
```

## Building the signature

```
body_hash = sha256_hex(raw_body_bytes)                 # empty body → sha256("")
canonical = timestamp + "\n" + METHOD + "\n" + path_with_query + "\n" + body_hash
signature = hex( HMAC-SHA512( key = secret, message = canonical ) )
```

<Warning>
  **Sign the exact bytes you send.** Serialise the JSON once, hash those bytes, send those bytes. Do not let your HTTP client re-serialise the body after you signed it.
</Warning>

Rules that trip people up:

* **The path includes the query string** and excludes the host: `/partner/v1/orders?status=completed&limit=20`.
* **METHOD is upper-case.**
* **The signature is lower-case hex**, 128 characters.
* Keep your clock on NTP; a drift over 5 minutes fails every request.

## Worked example

Check your implementation byte for byte against these fixed inputs:

```
secret:    sk_test_EXAMPLE_ONLY_do_not_use
timestamp: 1759567200
method:    POST
path:      /partner/v1/orders
body:      {"investment_id":"3f2b8c1e-0d4a-4c51-9a77-2b6f1e9d0c11","amount":"5000","currency":"NGN","payment_mode":"wallet"}
```

The canonical string is the four parts joined with real newlines. The resulting header values are printed on the live reference page at `https://api.myladder.africa/partner/docs/` under "Worked example", computed by the same code the server verifies with.

## Code

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, json, time, uuid
  import requests

  BASE = "https://api.myladder.africa"
  KEY_ID, SECRET = "pk_test_…", "sk_test_…"

  def call(method, path, payload=None, idempotency_key=None):
      body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode()
      ts = str(int(time.time()))
      canonical = "\n".join([ts, method.upper(), path, hashlib.sha256(body).hexdigest()])
      signature = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha512).hexdigest()
      headers = {"X-Ladder-Key": KEY_ID, "X-Ladder-Timestamp": ts,
                 "X-Ladder-Signature": signature, "Content-Type": "application/json"}
      if method.upper() == "POST":
          headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
      return requests.request(method, BASE + path, data=body, headers=headers, timeout=30)
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");
  const BASE = "https://api.myladder.africa";
  const KEY_ID = "pk_test_…", SECRET = "sk_test_…";

  async function call(method, path, payload, idempotencyKey) {
    const body = payload === undefined ? "" : JSON.stringify(payload);
    const ts = Math.floor(Date.now() / 1000).toString();
    const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
    const canonical = [ts, method.toUpperCase(), path, bodyHash].join("\n");
    const signature = crypto.createHmac("sha512", SECRET).update(canonical).digest("hex");
    const headers = { "X-Ladder-Key": KEY_ID, "X-Ladder-Timestamp": ts,
                      "X-Ladder-Signature": signature, "Content-Type": "application/json" };
    if (method.toUpperCase() === "POST") headers["Idempotency-Key"] = idempotencyKey || crypto.randomUUID();
    return fetch(BASE + path, { method, headers, body: body || undefined });
  }
  ```
</CodeGroup>

## Document uploads

`POST /kyb/documents` is `multipart/form-data`. Build the multipart body first, sign those exact bytes, and send them unchanged with the matching `Content-Type` boundary. Most HTTP clients generate the multipart body as they send, which breaks the signature, so build it yourself (for example `requests_toolbelt.MultipartEncoder(...).to_string()` in Python).

## Troubleshooting

| Status | `error.code` | Usual cause |
| - | - | - |
| 401 | `authentication_required` | none of the three `X-Ladder-*` headers was sent |
| 401 | `authentication_failed` | a header is missing; the timestamp is not unix seconds or is more than 5 minutes off; the key is wrong or revoked; the signature is wrong (body re-serialised, query string left out, lower-case method, host in the path); a live key used before approval |
| 403 | `forbidden` | the request came from an IP outside your organisation's allowlist |
| 400 | `idempotency_key_required` | a POST without `Idempotency-Key` |
| 429 | `rate_limited` | over 120 requests per minute on this key; honour `Retry-After` |

The response `message` says which of these it was; branch on `code`, read `message` when debugging.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.