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.
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
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.
- 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
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 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
- 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.
- 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).
- 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/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.
A complete authenticated request looks like this:
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:
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>/. Below is the minimal signing function for each language.
Node.js / TypeScript
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
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)
# 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
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:
# 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 |