Environments
Every key belongs to one environment. Sandbox keys (lhg_sbx_…) exercise the full API with no real-world side effects: e-filing goes to the courts' test system, serve jobs are flagged sandbox and never dispatched, no email reaches a real person, no card is charged. Production keys (lhg_prd_…) do the real thing. Both environments live at this same base URL — the key decides.
Authentication — HMAC-SHA256 request signing
You hold a key id (public) and a secret (shown once when the key is issued — store it in your secret manager; we cannot show it again, only rotate it). Every request except GET /v1/health carries four headers:
| Header | Value |
|---|---|
LHG-Key-Id | Your key id |
LHG-Timestamp | Unix seconds at signing time (accepted within ±300s of server time) |
LHG-Nonce | A unique string per request, at most 64 characters (random hex is fine). Each nonce is accepted once. |
LHG-Signature | Lowercase hex HMAC-SHA256 of the canonical string, keyed by your secret |
The canonical string
Five lines joined by \n (a literal newline, no trailing newline):
METHOD uppercase — GET
PATH path incl. query string — /v1/partner
TIMESTAMP exactly as sent in LHG-Timestamp
NONCE exactly as sent in LHG-Nonce
SHA256(BODY) lowercase hex of the raw request body;
for GET / empty body use the empty hash:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Worked example
Computed with the server's own signing code — reproduce these exact values and your implementation is correct. Request bodies, when you send one, must be JSON (Content-Type: application/json); hash the exact bytes you send.
key id: lhg_sbx_k_9f2c41d8a6b3e07f
secret: lhg_sbx_s_0000000000000000000000000000000000000000000000000000000000000000
request: GET https://api.legalhubgroup.com/v1/partner (no body)
timestamp: 1758400000
nonce: 5f2b9c0d7e4a13c6
canonical string (shown with \n visible):
GET\n
/v1/partner\n
1758400000\n
5f2b9c0d7e4a13c6\n
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
signature = HMAC_SHA256(secret, canonical) =
2c4fa8d0d3329eaaec62b0505030090101c84f2fc4dbec9b66e53734171ee165
// Node.js
const crypto = require('crypto');
function signedHeaders(keyId, secret, method, path, body) {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString('hex');
const bodyHash = crypto.createHash('sha256').update(body || Buffer.alloc(0)).digest('hex');
const canonical = [method.toUpperCase(), path, ts, nonce, bodyHash].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
return { 'LHG-Key-Id': keyId, 'LHG-Timestamp': ts, 'LHG-Nonce': nonce, 'LHG-Signature': signature };
}
// const res = await fetch('https://api.legalhubgroup.com/v1/partner',
// { headers: signedHeaders(KEY_ID, SECRET, 'GET', '/v1/partner') });
Endpoints
| Endpoint | Auth | What it does |
|---|---|---|
GET /v1/health | none | Connectivity check — proves DNS, TLS and routing before you write signing code |
GET /v1/partner | signed | Returns your partner name, environment, scopes, rate limit and IP allowlist — the first signed call to make |
GET /v1/openapi.json | none | This API as an OpenAPI 3 document |
Scopes (efile, serve, concierge) gate the e-filing, process-serving and concierge endpoints that ship in later phases; your key's granted scopes appear in GET /v1/partner.
Rate limits
Per key, default 60 requests/minute (sliding window; configurable per key — ask us if you need more). Exceeding it returns 429 with a Retry-After header. Back off and retry; do not tight-loop.
Errors
Every failure is JSON: {"error":{"code":"…","message":"…"}}. The code is stable; branch on it, not on the message.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | missing_key / unknown_key / revoked_key | The LHG-Key-Id header is absent, unknown, or the key was revoked |
| 401 | invalid_timestamp | LHG-Timestamp malformed or outside the ±300s window — check clock skew |
| 401 | invalid_signature | Signature mismatch — recheck the canonical string, especially the query string and body hash |
| 401 | nonce_replayed | That nonce was already used with this key — generate a fresh one per request |
| 403 | ip_not_allowed | Your source IP is not on the key's allowlist |
| 403 | partner_inactive / insufficient_scope | Account inactive, or the key lacks the scope the endpoint needs |
| 429 | rate_limited | Over the per-key limit — respect Retry-After |
| 404 | not_found | No such endpoint |
Webhooks
Each key can register one HTTPS webhook URL with its own signing secret (issued when the URL is set, shown once). We POST JSON events to it:
{
"id": "whd_123", // delivery id — dedupe on it; retries reuse it
"type": "test.ping", // event type
"environment": "sandbox", // sandbox events never mean real-world activity
"created_at": "2026-09-21T18:00:00.000Z",
"data": { … }
}
| Header | Value |
|---|---|
LHG-Webhook-Id | Delivery id (same as body id) |
LHG-Event | Event type |
LHG-Webhook-Timestamp | Unix seconds at send time |
LHG-Webhook-Signature | Hex HMAC-SHA256 of "<timestamp>.<raw body>" with your webhook secret |
Verify by recomputing the HMAC over the timestamp header, a dot, and the raw body bytes, then compare constant-time; reject timestamps older than a few minutes. Answer 2xx quickly (do slow work async). Non-2xx or a timeout is retried with exponential backoff (2, 4, 8… minutes, 8 attempts). The only event so far is test.ping, sent from the LHG admin console; e-filing, serve and concierge events arrive with those phases.
Legal Hub Group · questions: [email protected]