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

# Signing requests

> HMAC-SHA256 over a canonical string — the secret never travels

Every `/orders` request carries an HMAC-SHA256 signature over a canonical
string. The catalogue is open; everything else is signed. **The secret never
travels.**

## Credentials

| Item   | Sent as               | Description                                        |
| ------ | --------------------- | -------------------------------------------------- |
| Key id | `X-Api-Key-Id`        | Public. Identifies the credential.                 |
| Secret | **never transmitted** | The HMAC key. Store it as you would a private key. |

Both halves are replaced together on rotation. A key carries a fixed set of
scopes, so polling and retrieval can use different keys.

## Request headers

| Header            | Required    | Format               | Description                                                 |
| ----------------- | ----------- | -------------------- | ----------------------------------------------------------- |
| `X-Api-Key-Id`    | Required    | string               | Your key id, verbatim.                                      |
| `X-Signature`     | Required    | `t=<int>,v1=<hex64>` | The timestamp inside `t=` is the one validated.             |
| `X-Timestamp`     | Optional    | unix seconds         | Carried for symmetry. Send the same value as `t=`.          |
| `Idempotency-Key` | `POST` only | non-empty string     | Required on `POST /orders`. Absent is a `VALIDATION_ERROR`. |
| `Content-Type`    | `POST` only | `application/json`   | —                                                           |

## The canonical string

Four components, joined by a single newline. Nothing else is signed.

```text theme={null}
<unix-seconds>
<HTTP-METHOD>
<path, query string excluded>
<sha256 hex of the raw request body>
```

```text theme={null}
v1 = hex( HMAC-SHA256( secret, canonical ) )
```

### Four rules decide whether it verifies

| Rule                                                                              | Why it bites                                                                                                                                               |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The path excludes the query string.** Sign `/orders`, never `/orders?limit=25`. | Adding a filter to a `GET` must not invalidate a signature you already computed correctly.                                                                 |
| **The body is hashed, not included.** With no body, hash zero bytes.              | That is `e3b0c442…7852b855`, the SHA-256 of the empty string. Omitting the component shifts every other line.                                              |
| **Hash the exact bytes you transmit.**                                            | Re-serialising after hashing — reordered keys, added whitespace — signs bytes we never receive. Generate any random value in the body *before* hashing it. |
| **Whole seconds, upper-case method.**                                             | A millisecond timestamp fails the skew check by a factor of a thousand.                                                                                    |

## Worked example

Secret `whsec_example_do_not_use`, timestamp `1758470400`. These values are
reproducible — run your signer against them before sending a real request.

```text POST with a body theme={null}
# body, exactly as transmitted (186 bytes, no trailing newline)
{"partner_order_ref":"ORD-10294","amount_minor":"100000","currency":"INR",
 "payment_ref":"pi_3QXk2s","items":[{"canonical_sku":"BIGBASKET--IN--INR",
 "denomination_minor":"50000","qty":2}]}

# sha256(body)
8f1461ff1c94be19def04a755ff0c09e506454d11acc0febd3935693d117574c

# canonical string
1758470400
POST
/orders
8f1461ff1c94be19def04a755ff0c09e506454d11acc0febd3935693d117574c

# resulting header
X-Signature: t=1758470400,v1=c98410ba845c43767feac8ea2cf7f507346ec12593bdfe20ca9de67518a8c1f2
```

```text GET with no body theme={null}
# canonical string for GET /orders/9f1c8a44-2b7e-4d31-9a6f-5c0e7b2d81a3
1758470400
GET
/orders/9f1c8a44-2b7e-4d31-9a6f-5c0e7b2d81a3
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

# resulting header
X-Signature: t=1758470400,v1=9fbdc1cc747c72f20fb365feec22eb165ea7d22ba54aa728139f491d24410437
```

### Reference implementation

<CodeGroup>
  ```javascript Node.js theme={null}
  const { createHash, createHmac } = require('node:crypto');

  function sign(secret, method, path, body = '') {
    const t = Math.floor(Date.now() / 1000);
    const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');
    const canonical = [String(t), method.toUpperCase(), path, bodyHash].join('\n');
    const v1 = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
    return { 'X-Timestamp': String(t), 'X-Signature': `t=${t},v1=${v1}` };
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  def sign(secret: str, method: str, path: str, body: bytes = b''):
      t = int(time.time())
      body_hash = hashlib.sha256(body).hexdigest()
      canonical = '\n'.join([str(t), method.upper(), path, body_hash])
      v1 = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
      return {'X-Timestamp': str(t), 'X-Signature': f't={t},v1={v1}'}
  ```
</CodeGroup>

## Clock skew and replay

| Control          | Value  | Applies to                        | Effect                                                                                     |
| ---------------- | ------ | --------------------------------- | ------------------------------------------------------------------------------------------ |
| Skew window      | ±300 s | Every signed request              | Outside it, `AUTH_TIMESTAMP_SKEW` — even when the signature is correct. Keep hosts on NTP. |
| Replay cache     | 600 s  | `POST /orders`, voucher retrieval | A signature seen twice is refused as `AUTH_BAD_SIGNATURE`.                                 |
| Replay exemption | —      | `GET /orders`, `GET /orders/{id}` | Poll as fast as the rate limit allows, including with an identical signature.              |

<Note>
  Because the timestamp is whole seconds, two identical replay-protected requests
  inside the same second produce the same signature and the second is refused.
  Space them, or let the retry carry a fresh timestamp.
</Note>

## Scopes

| Scope           | Grants                                  | Notes                                                                                                          |
| --------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `orders:write`  | `POST /orders`                          | —                                                                                                              |
| `orders:read`   | `GET /orders`, `GET /orders/{order_id}` | —                                                                                                              |
| `vouchers:read` | `GET /orders/{order_id}/vouchers`       | **Not implied by `orders:read`.** Issue it to a separate key so a leaked polling credential cannot read codes. |

## Authentication failures

| Code                  | HTTP  | Cause                                                          |
| --------------------- | ----- | -------------------------------------------------------------- |
| `AUTH_INVALID_KEY`    | `401` | Missing headers, unknown key id, or an inactive key.           |
| `AUTH_BAD_SIGNATURE`  | `401` | Malformed `X-Signature`, wrong signature, or one already used. |
| `AUTH_TIMESTAMP_SKEW` | `401` | Timestamp outside ±300 seconds.                                |
| `SCOPE_FORBIDDEN`     | `403` | Authenticated, but the key lacks the scope for this route.     |

<Note>
  The first two are deliberately indistinguishable in several cases: an unknown
  key, a revoked key and a bad signature all answer alike, so an attacker learns
  nothing about which half was wrong. Both mean *fix the credential or the signing
  code*.
</Note>
