Skip to main content

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

  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 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

PropertyValue
Curvesecp256k1
HashSHA-256
Signature formatDER, then Base64
Public key encodingPEM SPKI, then Base64 (the apiKey)
Private key encodingPEM 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:
MethodCanonical payload
POST, PATCH, PUTCompact JSON of the request body — JSON.stringify(body) with no whitespace between separators
GET, DELETE with query paramsURL-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

RequestCanonical payload (the string you sign)
GET /api/v1/dapp/strains?countryCode=GBRcountryCode=GBR
GET /api/v1/dapp/strains?countryCode=GBR&page=1&limit=10countryCode=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:
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 prefixStrategyWho uses it
/api/v1/auth/*, /api/v1/public/*NoneAnyone (login, nonce, health)
/api/v1/dapp/*DualAuthGuard — accepts JWT or API-key+signatureDAPP UI (JWT) and external stores (API-key+sig)
/api/v1/user/*, /api/v1/dapp/users/nftsJWT onlyDAPP 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)

SymptomLikely causeFix
401 "User is not authorized" (76 bytes) on a GET with no queryYou signed ""; correct is "{}"Update canonical_payload to return "{}" when query is empty
401 "User is not authorized" on a POSTBody whitespace differs between sign and sendStringify once, sign, send the exact string
401 "Unauthorized" (43 bytes) on /user/me or /dapp/users/nftsThese routes are JWT-only, not API-keyYou 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 GETYou sent the canonical payload as the request body on a GETGETs have no body. Send the body only on POST/PATCH/PUT
401 on a request that worked yesterdayAPI key was deactivated by the holderGet a new pair from the holder
401 on the very first requestWrong canonical payload, wrong key encoding, or wrong curve. Verify locally firstUse the local verification script above