REST API (Tool API & Server-to-Server)
The browser SDK talks to the Tool API. You can call the same API from your own backend — to record consents collected in your own UI, to mint one-time approval tokens, or to look up and verify records.
Base URL: https://system.hashentry.com
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /tool/v1/config | Banner configuration, attached documents, detected region | API key (read) |
GET | /tool/v1/documents/{slug}/active | Active version of one document, including content | API key (read) |
GET | /tool/v1/documents/active | Active documents attached to the site | API key (read) |
POST | /tool/v1/consents | Record one consent (browser / mobile SDK route) | API key (write) + signature |
POST | /tool/v1/consents/batch | Record up to 100 consents | API key (write) + signature |
POST | /tool/v1/s2s/consents | Record one consent from your backend | API key (write) + JWT + signature |
POST | /tool/v1/s2s/consents/batch | Record up to 100 consents from your backend | API key (write) + JWT + signature |
POST | /tool/v1/s2s/approval-tokens | Mint a one-time document approval token | API key (write) + JWT + signature |
GET | /tool/v1/consents/status | Current consents of one visitor | API key (read) |
GET | /tool/v1/consents/verify/{id} | Integrity check of one record | API key (read) |
GET POST | /api/v1/public/consents/verify | Public integrity check (no key) | none |
Authentication
Section titled “Authentication”API key
Section titled “API key”Every Tool API request carries the site’s key:
X-API-Key: he_live_…A key has a scope: read allows the GET endpoints, write additionally allows POST. The site must be verified in the dashboard. If the site has an allowed-origins list, browser requests from other origins are rejected; if it has an IP allow-list, requests from other addresses are rejected.
Request signature (all POST requests)
Section titled “Request signature (all POST requests)”X-Signature: <hex HMAC-SHA256>X-Timestamp: <Unix time in seconds>X-Nonce: <unique value per request>body_hash = SHA-256(raw request body) (lowercase hex)canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hashsignature = HMAC-SHA256(key = API key, message = canonical) (lowercase hex)METHODis upper case.PATHis the request path including/tool/v1, without the query string, e.g./tool/v1/s2s/consents.- The body hash is computed over the exact bytes you send. Serialise the JSON once and send that same string.
X-Timestampmust be within ±300 seconds of server time.X-Nonceis 8–128 characters fromA–Z a–z 0–9 . _ : -(a UUID works) and can be used only once per site; a repeat is rejected withNONCE_REPLAYED.
Worked example (the shared test vector of the SDK and the API):
API key : he_live_0123456789abcdef0123456789abcdefMethod : POSTPath : /tool/v1/consentsTimestamp : 1760000000Nonce : 3f2b8c1e-5a47-4d0e-9b6a-7c1d2e3f4a5bBody : {"consent_type":"cookie","decision":"accepted","language":"tr","idempotency_key":"9a1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d","metadata":{"note":"çerez ✓"}}
body_hash : 93316d8d6adb505a19668f4022e4e33b12aed9c0c4bb97d205b5064257090d34canonical : POST\n/tool/v1/consents\n1760000000\n3f2b8c1e-5a47-4d0e-9b6a-7c1d2e3f4a5b\n93316d8d6adb505a19668f4022e4e33b12aed9c0c4bb97d205b5064257090d34signature : e108683927badf35c36d5f467f63a9704c131d0102f14b06af7ed3046d44a8be(\n stands for a single line-feed character; the body is UTF-8.) If your implementation produces this signature for these inputs, it is correct.
const crypto = require('crypto');
function sign(apiKey, method, path, body) { const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = crypto.randomUUID(); const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex'); const canonical = [method.toUpperCase(), path, timestamp, nonce, bodyHash].join('\n'); const signature = crypto.createHmac('sha256', apiKey).update(canonical, 'utf8').digest('hex'); return { 'X-Signature': signature, 'X-Timestamp': timestamp, 'X-Nonce': nonce };}
const body = JSON.stringify({ consent_type: 'document_approval', decision: 'accepted', /* … */ });const headers = { 'Content-Type': 'application/json', 'X-API-Key': apiKey, Authorization: `Bearer ${jwt}`, // S2S routes only ...sign(apiKey, 'POST', '/tool/v1/s2s/consents', body),};The signature proves that the request was made with the API key and was not replayed. Because the browser SDK uses the same key, which is visible in page source, the signature alone does not prove that a request came from your backend — that is what the S2S routes are for.
Server-to-server routes (/tool/v1/s2s/…)
Section titled “Server-to-server routes (/tool/v1/s2s/…)”S2S requests need three things: a write API key, the request signature, and a JWT of a user with the api_user role:
- Invite a team member with the role
api_userin the dashboard. This role can only use the S2S API; it cannot sign in to the panel. - Sign in with
POST /api/v1/auth/loginto obtain a JWT; renew it withPOST /api/v1/auth/refresh. - Send
Authorization: Bearer <jwt>together withX-API-Keyand the signature headers. The user must belong to the tenant that owns the site.
Only S2S requests may set ip_address, user_agent, otp_verified and otp_reference. On the browser route the IP address and user agent are always taken from the connection, and the OTP fields are ignored.
Recording a consent
Section titled “Recording a consent”POST /tool/v1/consents (or /tool/v1/s2s/consents)
{ "consent_type": "cookie", "decision": "partial", "document_version_id": "0190a1b2-…", "language": "en", "categories": { "necessary": true, "analytics": true, "marketing": false }, "idempotency_key": "9a1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d", "visitor_id": "user-4711", "visitor_region": "DE", "compliance_framework": "gdpr", "metadata": { "order_id": "ORD-54321" }}| Field | Rules |
|---|---|
consent_type | Required. Up to 50 characters (A–Z a–z 0–9 _ - .). In use: cookie, document_approval, do_not_sell_opt_out. |
decision | Required. accepted, rejected, partial or withdrawn. |
document_version_id | UUID of an active version of a document attached to the site. Without it — or with a version that is not active — the request is rejected with 409 CONFIG_OUTDATED. A cookie consent references the site’s cookie_policy document. |
language | If given, it must be the language of that version. |
categories | Object of category key → boolean. Only categories defined in the banner configuration are stored. |
idempotency_key | 8–64 characters (A–Z a–z 0–9 . _ : -). See below. |
visitor_id | Your reference for the person, up to 100 characters. |
visitor_region, visitor_country | Up to 10 / 2 characters. |
compliance_framework | kvkk, gdpr, uk_gdpr, ccpa, lgpd, popia, pipeda, pipl, pipa or none. |
source | Up to 20 characters, e.g. client, server. |
metadata, extra_fields | Objects with at most 30 keys and about 16 KB each. |
approval_token | One-time token for a document_approval (see below). |
| Signature / custom document fields | signature_image, signature_mime_type, signer_name, signer_email, signer_phone, signer_id_number, declaration_text, drawing_duration_ms, stroke_count, strokes_data, input_type, screen_resolution, custom_document_content, custom_document_mime_type — all top-level. See Document Approval & Signature. |
ip_address, user_agent, otp_verified, otp_reference | S2S only. |
Get the document_version_id from GET /tool/v1/config?language=en (data.documents[].version.id) or from GET /tool/v1/documents/{slug}/active?language=en.
Response: 202 Accepted
Section titled “Response: 202 Accepted”{ "message": "…", "data": { "consent_token": "ct_…", "proof_hash": "…64 hex characters…", "created_at": "2026-10-10T10:00:00+00:00", "duplicate": false }}202 means the request passed all checks and the record was accepted for writing. The token, the proof hash and the timestamp are final at that point, but the row is written by a background worker, so status and verify may not find it for a short moment.
idempotency_key
Section titled “idempotency_key”Send one key per user action and reuse it on every retry of that action. A repeated request with the same key does not create a second record and does not consume quota; it returns the original response with "duplicate": true. Keys are remembered for 24 hours. The browser SDK generates one UUID per decision and uses it for the first attempt, client retries and replays from its offline queue.
POST /tool/v1/consents/batch with { "consents": [ { … }, … ] } (1–100 items, same fields as above).
{ "data": { "accepted": 2, "rejected": 1, "results": [ { "index": 0, "status": "accepted", "consent_token": "ct_…", "proof_hash": "…", "created_at": "…", "duplicate": false }, { "index": 1, "status": "accepted", "consent_token": "ct_…", "proof_hash": "…", "created_at": "…", "duplicate": true }, { "index": 2, "status": "rejected", "error": "…", "error_code": "CONSENT_LIMIT_EXCEEDED", "code": 429 } ] }}The status is 202 when the batch was processed; check each entry of results. Validation errors and an item with an unknown or inactive document version reject the whole batch (422 / 409).
One-time approval tokens
Section titled “One-time approval tokens”POST /tool/v1/s2s/approval-tokens — S2S only.
{ "document_slug": "service-contract", "subject": "user-4711", "signer_name": "Jane Doe", "expires_in": 600}| Field | Rules |
|---|---|
document_slug / document_version_id | At least one. With a version id the token is valid for that version only. |
subject | Required. Your reference for the person (max. 100 characters); stored as the record’s visitor_id. |
signer_name, signer_email, signer_phone, signer_id_number | Optional. Identity details you vouch for. |
otp_verified, otp_reference | Optional. Declare an OTP check you performed. |
custom_document_hash | Optional. SHA-256 (hex) of a document generated by your system; the approval must then carry exactly that content. |
expires_in | Seconds, 30–3600. Default 600. |
Response 201:
{ "data": { "approval_token": "hat_…", "approval_token_id": "atk_…", "expires_in": 600, "expires_at": "2026-10-10T10:10:00+00:00", "document_id": "…", "document_slug": "service-contract", "document_version_id": null }}Give approval_token to the page and pass it to the SDK (approvalToken) or send it as top-level approval_token of a document_approval consent. The server consumes it once, takes the identity from the token and records identity_assurance: tenant_attested together with the non-secret approval_token_id. A request that is rejected for another reason (rate limit, quota) does not consume the token. HashEntry stores only a hash of the token.
Reading and verifying
Section titled “Reading and verifying”GET /tool/v1/consents/status?visitor_id=…
Section titled “GET /tool/v1/consents/status?visitor_id=…”Returns up to 20 unexpired consents of one visitor_id, newest first. Optional filters: consent_type, document_slug, document_version_id.
GET /tool/v1/consents/verify/{id}
Section titled “GET /tool/v1/consents/verify/{id}”{id} is a consent UUID, a consent_token (ct_…) or a proof_hash.
{ "valid": true, "data": { "consent_type": "cookie", "decision": "accepted", "proof_hash": "…", "hash_version": 2, "hash_valid": true, "merkle_valid": null, "is_anonymized": false, "is_tsa_stamped": false, "created_at": "2026-10-10T10:00:00+00:00" }}What this answer means — and what it does not:
valid: truemeans: the record belongs to your tenant, HashEntry recomputed the proof hash from the stored record with your tenant’s secret key and it matches, and — if the record is part of a Merkle batch — its inclusion proof is correct. The existence of a row is not enough.merkle_validisnullwhen the record has no Merkle proof.is_tsa_stampedsays that a timestamp token exists for the record (directly or through its Merkle batch). It does not say that the token’s signature was verified in this call.- This is a check performed by HashEntry on HashEntry’s data. To verify without trusting the server’s answer, export an evidence package and verify it yourself: Verifying evidence offline.
- An unknown id returns
404with"valid": falseandcode: "CONSENT_NOT_FOUND".
GET|POST /api/v1/public/consents/verify
Section titled “GET|POST /api/v1/public/consents/verify”No API key; rate limited. Parameters: token (a ct_… token or the value of the SDK’s _he_consent cookie), proof_hash or id. It returns the decision, categories, site domain, timestamps, hash status and timestamp information, and never the IP address, user agent or visitor id.
- A record that exists:
200withvalidas described above. - A browser cookie value that cannot be matched to a server record:
200with"valid": false,"source": "client_token","server_record_found": falseand the decoded cookie fields. The cookie is not signed, so it is never reported as valid on its own. - Nothing found:
404with"valid": false. No identifier:422.
Error codes
Section titled “Error codes”Error responses are JSON and carry a stable machine code in code (also error_code and error.code; some authentication errors only use error.code).
| HTTP | Code | Meaning |
|---|---|---|
| 401 | MISSING_API_KEY, INVALID_API_KEY | No or unknown API key. |
| 401 | MISSING_SIGNATURE_HEADERS, TIMESTAMP_EXPIRED, INVALID_SIGNATURE, INVALID_NONCE, NONCE_REPLAYED | Request signature problems. |
| 401 | MISSING_BEARER_TOKEN, INVALID_BEARER_TOKEN | S2S: JWT missing or invalid. |
| 403 | SITE_NOT_VERIFIED | Verify the site in the dashboard. |
| 403 | ORIGIN_NOT_ALLOWED, IP_NOT_ALLOWED | Not in the site’s allow-list. |
| 403 | INSUFFICIENT_ROLE, TENANT_MISMATCH | S2S: user is not an api_user of this tenant. |
| 403 | — | API key scope does not allow the method. |
| 404 | DOCUMENT_NOT_FOUND, VERSION_NOT_FOUND, CONSENT_NOT_FOUND | |
| 409 | CONFIG_OUTDATED | document_version_id missing, not active, wrong language, or the server’s configuration cache is cold. Re-read /tool/v1/config; resend only if the version is still the active one. |
| 422 | VALIDATION_ERROR | Field errors are in errors. |
| 422 | APPROVAL_TOKEN_REQUIRED, APPROVAL_TOKEN_INVALID, APPROVAL_TOKEN_CONFLICT | See one-time approval tokens. |
| 422 | INVALID_CATEGORIES, METADATA_TOO_MANY_KEYS, METADATA_TOO_LARGE, EXTRA_FIELDS_TOO_MANY_KEYS, EXTRA_FIELD_NAME_TOO_LONG, EXTRA_FIELDS_TOO_LARGE, STROKES_DATA_TOO_LARGE, INVALID_SIGNATURE_IMAGE, SIGNATURE_IMAGE_TOO_LARGE, INVALID_CUSTOM_DOCUMENT, UNSUPPORTED_CUSTOM_DOCUMENT_TYPE, CUSTOM_DOCUMENT_TOO_LARGE | Payload limits. |
| 429 | RATE_LIMIT_EXCEEDED | Per-plan rate limit. See Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining. |
| 429 | CONSENT_LIMIT_EXCEEDED, NO_CONSENT_SUBSCRIPTION | The plan’s consent quota is used up / there is no active plan. |
| 429 | TOO_MANY_INVALID_API_KEYS | Too many requests with invalid keys from one IP address. |
| 503 | QUEUE_OVERLOADED, ADMISSION_UNAVAILABLE, API_KEY_LOOKUP_UNAVAILABLE | Temporary. Retry after Retry-After with the same idempotency_key. |
Retry 408, 425, 429 and 5xx responses with the same body and the same idempotency_key (and a new timestamp, nonce and signature). Do not retry other 4xx responses unchanged.