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
- The Dr Green API authenticates each request with ECDSA (curve secp256k1) over SHA-256.
- Your
apiKeyis the Base64-encoded PEM public key (SPKI). YoursecretKeyis the Base64-encoded PEM private key (PKCS8). They come as a pair fromPOST /keys. - 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>
- The canonical payload depends on HTTP method (see § Canonical payload below). Sign and send the same exact string.
- Treat
secretKeylike 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) |
How keys are issued
Holders authenticate to the DAPP UI via wallet sign-in (SIWE-style):(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""produces401 "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
- Sign the exact string you’ll send. If you
JSON.stringifytwice, you may get different output (object key ordering varies between calls). Stringify once, sign that string, send that string. - No whitespace in JSON.
JSON.stringify(obj)with nospacearg gives compact form. In Python:json.dumps(obj, separators=(",", ":")). In PHP:json_encode($obj). - 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. - Send
Content-Type: application/jsononly 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: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:If your request is rejected withThese return
GET /api/v1/user/meGET /api/v1/dapp/users/nfts401 "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.
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>/. Below is the minimal signing function for each language.
Node.js / TypeScript
Python
cURL (bash)
PHP
Verifying your signature locally
Before you fire requests against the live API, verify your signing works locally: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
secretKeyto git. Use.envand 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/deleteandPOST /keys. - There is no replay protection. Anyone with your
secretKeycan 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 |