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

# 02 authentication

# Authentication & Request Signing

> **Read this entire page before you write a single line of integration code.** Every authenticated endpoint requires a per-request cryptographic signature. Get this wrong and every call returns `401 Unauthorized` regardless of what else you do.
>
> ⚠️ **If you are arriving here from the older Postman documentation, please discard it.** It used the wrong algorithm. This document reflects the actual deployed behaviour as of 10 May 2026, **verified live against production**.

***

## TL;DR

1. The Dr Green API authenticates each request with **ECDSA (curve secp256k1) over SHA-256**.
2. Your `apiKey` is the Base64-encoded PEM **public key** (SPKI). Your `secretKey` is the Base64-encoded PEM **private key** (PKCS8). They come as a pair from `POST /keys`.
3. Every authenticated request sends two headers:
   * `x-auth-apikey: <Base64 of PEM public key>`
   * `x-auth-signature: <Base64 of ECDSA-SHA256 signature over the canonical payload>`
4. The **canonical payload** depends on HTTP method (see [§ Canonical payload](#canonical-payload) below). **Sign and send the same exact string.**
5. Treat `secretKey` like a database password — there's no replay protection, so anyone with it can sign as you.

***

## The cryptographic primitive

| Property             | Value                                    |
| -------------------- | ---------------------------------------- |
| Curve                | secp256k1                                |
| Hash                 | SHA-256                                  |
| Signature format     | DER, then Base64                         |
| Public key encoding  | PEM SPKI, then Base64 (the `apiKey`)     |
| Private key encoding | PEM PKCS8, then Base64 (the `secretKey`) |

This is the same primitive Bitcoin and Ethereum use for transaction signatures. Every mainstream language has good library support. See [§ Implementation by language](#implementation-by-language) for working code.

***

## How keys are issued

Holders authenticate to the DAPP UI via wallet sign-in (SIWE-style):

```
1. Browser:  POST /api/v1/auth/nonce  →  receives a nonce
2. Browser:  signs nonce with wallet
3. Browser:  POST /api/v1/auth/dapp/signIn { nonce, signature, walletAddress }
                                          →  receives a JWT
4. DAPP UI:  uses JWT for all UI operations
5. Holder:   navigates to API Keys section in DAPP UI
6. DAPP UI:  POST /api/v1/keys (JWT auth)  →  receives { apiKey, secretKey }
7. Holder:   hands { apiKey, secretKey } to their store developer
8. Store:    uses apiKey + signature on every /dapp/... request
```

Stores **never** see the holder's wallet or JWT. They get a long-lived `(apiKey, secretKey)` pair the holder generated on their behalf in the DAPP UI. A holder can issue up to **100** key pairs and revoke any of them via `PATCH /keys/delete` at any time.

***

## Canonical payload

> ⚠️ **The most common cause of 401 errors during integration is signing the wrong payload.** Read this section twice.

The server reproduces a "canonical payload" from your incoming request and verifies your signature against it. **You must sign the byte-for-byte identical string the server will reproduce.**

The reproduction rules vary by HTTP method:

| Method                                                              | Canonical payload                                                                               |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `POST`, `PATCH`, `PUT`                                              | Compact JSON of the request body — `JSON.stringify(body)` with no whitespace between separators |
| `GET`, `DELETE` with query params                                   | URL-encoded query string — `urlencode(query)`, e.g. `"page=1&limit=10"`                         |
| `GET`, `DELETE` with no query params (with or without route params) | **`"{}"`** — JSON-stringified empty params object                                               |

> ⚠️ **Important correction (10 May 2026):** earlier drafts of this doc said the empty-GET case signs `""` (empty string). **It does not.** Verified live: empty-query GETs sign `"{}"`. Signing `""` produces `401 "User is not authorized"`. This applies to routes both with and without path parameters.

### Examples

| Request                                                                                 | Canonical payload (the string you sign)            |
| --------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `GET /api/v1/dapp/strains?countryCode=GBR`                                              | `countryCode=GBR`                                  |
| `GET /api/v1/dapp/strains?countryCode=GBR&page=1&limit=10`                              | `countryCode=GBR&page=1&limit=10`                  |
| `GET /api/v1/dapp/clients`                                                              | `{}`                                               |
| `GET /api/v1/dapp/clients/abc-123`                                                      | `{}`                                               |
| `GET /api/v1/dapp/clients/abc-123/orders`                                               | `{}`                                               |
| `POST /api/v1/dapp/orders` with body `{"clientId":"abc","strainId":"xyz","quantity":1}` | `{"clientId":"abc","strainId":"xyz","quantity":1}` |
| `PATCH /api/v1/dapp/users/primary-nft` with body `{"tokenId":56}`                       | `{"tokenId":56}`                                   |
| `DELETE /api/v1/dapp/carts/abc-123`                                                     | `{}`                                               |

### Critical rules

1. **Sign the exact string you'll send.** If you `JSON.stringify` twice, you may get different output (object key ordering varies between calls). Stringify once, sign that string, send that string.
2. **No whitespace in JSON.** `JSON.stringify(obj)` with no `space` arg gives compact form. In Python: `json.dumps(obj, separators=(",", ":"))`. In PHP: `json_encode($obj)`.
3. **Query params ordered as sent.** The server uses Express's `req.query`, which preserves insertion order. If you sort keys differently when signing vs. sending, you'll fail.
4. **Send `Content-Type: application/json` only when you have a body.** Setting it on a GET (with no body) doesn't break things, but it's not necessary; setting it on a GET *with the canonical-payload-string-as-body* will return 400 because Express's body-parser will try to JSON.parse it.

***

## The wire format

A complete authenticated request looks like this:

```http theme={null}
GET /api/v1/dapp/clients?page=1&limit=10 HTTP/1.1
Host: api.drgreennft.com
x-auth-apikey: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZZd0VBWUhLb1pJem...
x-auth-signature: MEUCIQDxmVRLk7gAJ+T8wKn0LWSc5VBDJ/T2Lr3...
```

For a POST:

```http theme={null}
POST /api/v1/dapp/orders HTTP/1.1
Host: api.drgreennft.com
Content-Type: application/json
Content-Length: 67
x-auth-apikey: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZZd0VBWUhLb1pJem...
x-auth-signature: MEYCIQDxmVRLk7gAJ+T8wKn0LWSc5VBDJ/T2Lr3...

{"clientId":"abc","strainId":"xyz","quantity":1,"shippingId":"sh1"}
```

Note the body in the POST is **byte-identical** to the canonical payload. That's not a coincidence — the canonical-payload-for-POST rule and the actual body are the same string.

***

## The three-layer auth flow

The Dr Green backend uses three different auth strategies depending on the route:

| Route prefix                                | Strategy                                             | Who uses it                                     |
| ------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
| `/api/v1/auth/*`, `/api/v1/public/*`        | None                                                 | Anyone (login, nonce, health)                   |
| `/api/v1/dapp/*`                            | `DualAuthGuard` — accepts JWT *or* API-key+signature | DAPP UI (JWT) and external stores (API-key+sig) |
| `/api/v1/user/*`, `/api/v1/dapp/users/nfts` | JWT only                                             | DAPP UI exclusively                             |

> ⚠️ **JWT-only routes you cannot reach from a store integration:**
>
> * `GET /api/v1/user/me`
> * `GET /api/v1/dapp/users/nfts`
>
> These return `401 "Unauthorized"` (the short-form message) regardless of how correct your API-key signature is. As a store builder, you need to ask the holder to set their primary NFT in the DAPP UI before they hand you keys — you can't list their NFTs from your side.

If your request is rejected with `401 "User is not authorized"` (the longer-form message), the DAPP guard is firing — your API key is being recognised but your signature doesn't match. Re-check your canonical payload.

***

## Implementation by language

Helper code is in [`/examples/<lang>/`](../examples/). Below is the minimal signing function for each language.

### Node.js / TypeScript

```typescript theme={null}
import { createPrivateKey, sign, createPublicKey } from 'node:crypto';
import { URLSearchParams } from 'node:url';

export function signPayload(secretKeyBase64: string, payload: string): string {
  const key = createPrivateKey({
    key: Buffer.from(secretKeyBase64, 'base64'),
    format: 'pem',
    type: 'pkcs8',
  });
  return sign('sha256', Buffer.from(payload, 'utf8'), key).toString('base64');
}

export function canonicalPayload(
  method: string,
  body: unknown = null,
  query: Record<string, unknown> = {},
): string {
  const m = method.toUpperCase();
  if (m === 'POST' || m === 'PATCH' || m === 'PUT') {
    if (body === null || body === undefined) return '';
    return typeof body === 'string' ? body : JSON.stringify(body);
  }
  // GET / DELETE
  const clean = Object.fromEntries(
    Object.entries(query).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]),
  );
  if (Object.keys(clean).length > 0) {
    return new URLSearchParams(clean).toString();
  }
  return '{}'; // ← critical: empty-query GET signs '{}', not ''
}

export function authHeaders(apiKey: string, secretKey: string, payload: string) {
  return {
    'x-auth-apikey': apiKey,
    'x-auth-signature': signPayload(secretKey, payload),
  };
}
```

### Python

```python theme={null}
import base64
import json
from urllib.parse import urlencode
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec

def sign_payload(secret_key_base64: str, payload: str) -> str:
    pem = base64.b64decode(secret_key_base64)
    priv = serialization.load_pem_private_key(pem, password=None)
    sig = priv.sign(payload.encode("utf-8"), ec.ECDSA(hashes.SHA256()))
    return base64.b64encode(sig).decode("ascii")

def canonical_payload(method: str, body=None, query=None) -> str:
    m = method.upper()
    if m in ("POST", "PATCH", "PUT"):
        if body is None: return ""
        if isinstance(body, str): return body
        return json.dumps(body, separators=(",", ":"))
    # GET / DELETE
    if query:
        clean = {k: str(v) for k, v in query.items() if v is not None}
        if clean: return urlencode(clean)
    return "{}"  # ← critical: empty-query GET signs '{}', not ''

def auth_headers(api_key: str, secret_key: str, payload: str) -> dict:
    return {
        "x-auth-apikey": api_key,
        "x-auth-signature": sign_payload(secret_key, payload),
    }
```

### cURL (bash)

```bash theme={null}
# sign.sh — usage: sign.sh <payload-string>  (reads ~/.drgreen/secret.pem)
set -euo pipefail
PAYLOAD="${1:-}"
KEY_FILE="${DRGREEN_KEY_FILE:-$HOME/.drgreen/secret.pem}"
printf '%s' "$PAYLOAD" | openssl dgst -sha256 -sign "$KEY_FILE" | base64 -w 0

# Decode your Base64 secret key to PEM, once:
echo "$DRGREEN_SECRET_KEY_B64" | base64 -d > ~/.drgreen/secret.pem
chmod 600 ~/.drgreen/secret.pem

# Authenticated GET with query:
PAYLOAD='page=1&limit=10'
SIG=$(./sign.sh "$PAYLOAD")
curl "https://api.drgreennft.com/api/v1/dapp/clients?$PAYLOAD" \
  -H "x-auth-apikey: $DRGREEN_API_KEY_B64" \
  -H "x-auth-signature: $SIG"

# Authenticated GET with no query — sign '{}':
SIG=$(./sign.sh '{}')
curl "https://api.drgreennft.com/api/v1/dapp/clients/abc-123" \
  -H "x-auth-apikey: $DRGREEN_API_KEY_B64" \
  -H "x-auth-signature: $SIG"

# Authenticated POST — sign and send the same JSON string:
PAYLOAD='{"tokenId":56}'
SIG=$(./sign.sh "$PAYLOAD")
curl -X PATCH "https://api.drgreennft.com/api/v1/dapp/users/primary-nft" \
  -H "Content-Type: application/json" \
  -H "x-auth-apikey: $DRGREEN_API_KEY_B64" \
  -H "x-auth-signature: $SIG" \
  --data "$PAYLOAD"
```

### PHP

```php theme={null}
<?php
function dr_green_sign(string $secretKeyBase64, string $payload): string {
    $pem = base64_decode($secretKeyBase64);
    $priv = openssl_pkey_get_private($pem);
    if ($priv === false) throw new RuntimeException('invalid private key');
    $sig = '';
    if (!openssl_sign($payload, $sig, $priv, OPENSSL_ALGO_SHA256)) {
        throw new RuntimeException('openssl_sign failed');
    }
    return base64_encode($sig);
}

function dr_green_canonical(string $method, $body = null, array $query = []): string {
    $m = strtoupper($method);
    if (in_array($m, ['POST', 'PATCH', 'PUT'], true)) {
        if ($body === null) return '';
        return is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }
    // GET / DELETE
    $clean = array_filter($query, fn($v) => $v !== null);
    if (!empty($clean)) return http_build_query($clean, '', '&', PHP_QUERY_RFC3986);
    return '{}'; // ← critical: empty-query GET signs '{}', not ''
}

function dr_green_headers(string $apiKey, string $secretKey, string $payload): array {
    return [
        'x-auth-apikey: ' . $apiKey,
        'x-auth-signature: ' . dr_green_sign($secretKey, $payload),
    ];
}
```

***

## Verifying your signature locally

Before you fire requests against the live API, verify your signing works locally:

```bash theme={null}
# Decode your keys
echo "$DRGREEN_API_KEY_B64"   | base64 -d > /tmp/pub.pem
echo "$DRGREEN_SECRET_KEY_B64" | base64 -d > /tmp/priv.pem

# Sign a test payload
PAYLOAD='{}'
printf '%s' "$PAYLOAD" | openssl dgst -sha256 -sign /tmp/priv.pem -out /tmp/sig.bin

# Verify with the public key
printf '%s' "$PAYLOAD" > /tmp/payload.bin
openssl dgst -sha256 -verify /tmp/pub.pem -signature /tmp/sig.bin /tmp/payload.bin
# Should print: Verified OK
```

If `Verified OK` prints locally but the API still returns 401, the bug is in your canonical-payload reproduction, not your crypto.

***

## Operational hygiene

* **Don't commit `secretKey` to git.** Use `.env` and add it to `.gitignore`. Use a secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault) in production.
* **Don't log signatures.** They're deterministic-ish (ECDSA randomises but with the key compromise risk is the same as logging the key).
* **Rotate keys when staff leave.** A holder issues up to 100 key pairs; deactivate and re-issue when needed via `PATCH /keys/delete` and `POST /keys`.
* **There is no replay protection.** Anyone with your `secretKey` can sign valid requests indefinitely. Treat it as a database-tier secret and confirm with Dr Green if/when timestamp-based replay protection ships.

***

## Common 401 causes (the diagnostic table)

| Symptom                                                             | Likely cause                                                                      | Fix                                                                                |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `401 "User is not authorized"` (76 bytes) on a GET with no query    | You signed `""`; correct is `"{}"`                                                | Update `canonical_payload` to return `"{}"` when query is empty                    |
| `401 "User is not authorized"` on a POST                            | Body whitespace differs between sign and send                                     | Stringify once, sign, send the exact string                                        |
| `401 "Unauthorized"` (43 bytes) on `/user/me` or `/dapp/users/nfts` | These routes are JWT-only, not API-key                                            | You can't reach these as a store. Ask the holder to set primary NFT in the DAPP UI |
| `400 "...is not valid JSON"` on a GET                               | You sent the canonical payload as the request body on a GET                       | GETs have no body. Send the body only on POST/PATCH/PUT                            |
| `401` on a request that worked yesterday                            | API key was deactivated by the holder                                             | Get a new pair from the holder                                                     |
| `401` on the very first request                                     | Wrong canonical payload, wrong key encoding, or wrong curve. Verify locally first | Use the local verification script above                                            |
