Authentication
Every request to the ProofAge API requires authentication. This guide explains the two authentication headers, how to construct HMAC signatures, and how to handle authentication errors.
Overview
ProofAge uses two headers for authentication:
| Header | Purpose | Required |
|---|---|---|
X-API-Key | Identifies your workspace. Obtained from the dashboard. | Always |
X-HMAC-Signature | HMAC-SHA256 signature proving you possess the secret key. | Required on most endpoints. Optional on POST /v1/verifications. |
API Key
Your API key is a public identifier for your workspace. Include it in every request:
X-API-Key: pk_live_abc123...You can find your API key in the Workspace Settings > API Keys section of the ProofAge dashboard.
The API key alone does not grant write access to most endpoints. It must be paired with a valid HMAC signature.
Secret Key
Your secret key is used to compute HMAC signatures. It is never sent over the wire.
Secret keys follow this format:
sk_live_<56 characters> # production
sk_test_<56 characters> # sandbox / testingStore your secret key securely. Never expose it in client-side code, version control, or logs.
HMAC Signature
The X-HMAC-Signature header contains a hex-encoded HMAC-SHA256 digest that proves you possess the secret key and that the request has not been tampered with.
Signing Standard Requests (JSON / No Body)
For requests that have a JSON body or no body at all, the canonical request is constructed as follows:
canonical = METHOD + path + bodyWhere:
- METHOD — The HTTP method in uppercase:
GET,POST,PUT,DELETE, etc. - path — The request path including query string if present. For example:
/v1/verificationsor/v1/consent?locale=en. - body — The raw request body exactly as sent. For
GETor requests with no body, use an empty string.
If the URL has a query string, include it as part of the path:
canonical = METHOD + path + '?' + queryString + bodyThen compute the signature:
signature = hex(HMAC-SHA256(canonical, secretKey))Example
For this request:
POST /v1/verifications/ver_abc123/consent
Content-Type: application/json
{"consent_version":"2.1","accepted":true}The canonical string is:
POST/v1/verifications/ver_abc123/consent{"consent_version":"2.1","accepted":true}Signing Multipart Requests (File Uploads)
File uploads use multipart/form-data, which requires a different canonical format:
canonical = METHOD + path [+ '?' + queryString] + '\n' + sortedFormFields + '\n' + sortedFileHashesWhere:
- METHOD + path — Same as standard requests.
- sortedFormFields — All non-file form fields, canonicalized:
- Sort field names alphabetically (recursively for nested fields).
- Serialize using
http_build_querywith RFC 3986 encoding. - Example:
external_id=user_123&type=selfie
- sortedFileHashes — For each uploaded file:
- Compute the SHA-256 hash of the file's raw bytes.
- Sort the hashes alphabetically.
- Join with commas.
- Example:
a1b2c3...,d4e5f6...
Example
For a request uploading a selfie with type=selfie:
POST/v1/verifications/ver_abc123/media
type=selfie
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855(Each section is separated by a newline character \n.)
Code Examples
Node.js
const crypto = require('crypto');
const fs = require('fs');
const API_KEY = 'YOUR_API_KEY';
const SECRET_KEY = 'YOUR_SECRET_KEY';
function signRequest(method, path, body = '') {
const canonical = method.toUpperCase() + path + body;
return crypto
.createHmac('sha256', SECRET_KEY)
.update(canonical)
.digest('hex');
}
function signMultipartRequest(method, path, formFields, filePaths) {
const sortedKeys = Object.keys(formFields).sort();
const params = new URLSearchParams();
for (const key of sortedKeys) {
params.append(key, formFields[key]);
}
const serializedFields = params.toString();
const fileHashes = filePaths
.map((fp) => {
const data = fs.readFileSync(fp);
return crypto.createHash('sha256').update(data).digest('hex');
})
.sort()
.join(',');
const canonical = method.toUpperCase() + path + '\n' + serializedFields + '\n' + fileHashes;
return crypto
.createHmac('sha256', SECRET_KEY)
.update(canonical)
.digest('hex');
}
// Example: sign a JSON request
const signature = signRequest(
'POST',
'/v1/verifications/ver_abc123/consent',
'{"consent_version":"2.1","accepted":true}'
);
console.log(signature);
// Example: sign a file upload
const uploadSignature = signMultipartRequest(
'POST',
'/v1/verifications/ver_abc123/media',
{ type: 'selfie' },
['./selfie.jpg']
);
console.log(uploadSignature);Python
import hashlib
import hmac
from urllib.parse import urlencode
API_KEY = "YOUR_API_KEY"
SECRET_KEY = "YOUR_SECRET_KEY"
def sign_request(method: str, path: str, body: str = "") -> str:
canonical = method.upper() + path + body
return hmac.new(
SECRET_KEY.encode(), canonical.encode(), hashlib.sha256
).hexdigest()
def sign_multipart_request(
method: str,
path: str,
form_fields: dict,
file_paths: list[str],
) -> str:
sorted_fields = dict(sorted(form_fields.items()))
serialized = urlencode(sorted_fields, quote_via=lambda s, *a: s)
file_hashes = []
for fp in file_paths:
with open(fp, "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()
file_hashes.append(digest)
file_hashes.sort()
canonical = method.upper() + path + "\n" + serialized + "\n" + ",".join(file_hashes)
return hmac.new(
SECRET_KEY.encode(), canonical.encode(), hashlib.sha256
).hexdigest()
# Example: sign a JSON request
signature = sign_request(
"POST",
"/v1/verifications/ver_abc123/consent",
'{"consent_version":"2.1","accepted":true}',
)
print(signature)
# Example: sign a file upload
upload_signature = sign_multipart_request(
"POST",
"/v1/verifications/ver_abc123/media",
{"type": "selfie"},
["./selfie.jpg"],
)
print(upload_signature)PHP
<?php
$apiKey = 'YOUR_API_KEY';
$secretKey = 'YOUR_SECRET_KEY';
function signRequest(string $method, string $path, string $body = ''): string
{
global $secretKey;
$canonical = strtoupper($method) . $path . $body;
return hash_hmac('sha256', $canonical, $secretKey);
}
function signMultipartRequest(
string $method,
string $path,
array $formFields,
array $filePaths,
): string {
global $secretKey;
ksort($formFields);
$serialized = http_build_query($formFields, '', '&', PHP_QUERY_RFC3986);
$fileHashes = [];
foreach ($filePaths as $fp) {
$fileHashes[] = hash_file('sha256', $fp);
}
sort($fileHashes);
$canonical = strtoupper($method) . $path . "\n" . $serialized . "\n" . implode(',', $fileHashes);
return hash_hmac('sha256', $canonical, $secretKey);
}
// Example: sign a JSON request
$signature = signRequest(
'POST',
'/v1/verifications/ver_abc123/consent',
'{"consent_version":"2.1","accepted":true}'
);
echo $signature . PHP_EOL;
// Example: sign a file upload
$uploadSignature = signMultipartRequest(
'POST',
'/v1/verifications/ver_abc123/media',
['type' => 'selfie'],
['./selfie.jpg']
);
echo $uploadSignature . PHP_EOL;Error Codes
When authentication fails, the API returns an error response with one of these codes:
| HTTP Status | Error Code | Description |
|---|---|---|
| 401 | MISSING_API_KEY | The X-API-Key header was not included in the request. |
| 401 | INVALID_API_KEY | The API key does not match any workspace. Check for typos or confirm the key is still active. |
| 403 | WORKSPACE_SUSPENDED | The workspace associated with the API key has been suspended. Contact support. |
| 401 | NO_SECRET_KEYS | The workspace has no secret keys. Generate one from the dashboard. |
| 401 | MISSING_SIGNATURE | The X-HMAC-Signature header is required for this endpoint but was not provided. |
| 401 | INVALID_SIGNATURE | The HMAC signature does not match the expected value. See troubleshooting below. |
All error responses follow this format:
{
"error": {
"code": "INVALID_SIGNATURE",
"message": "The HMAC signature does not match the expected value."
}
}Troubleshooting Signature Errors
If you receive INVALID_SIGNATURE, check the following:
- Method case — The HTTP method in the canonical string must be uppercase (
POST, notpost). - Body encoding — The body in the canonical string must be the exact raw bytes sent in the request. If you serialize JSON, make sure there are no extra spaces or key reordering between signing and sending.
- Path — Use the path without the domain:
/v1/verifications, nothttps://api.proofage.com/v1/verifications. - Query string — If present, include it after the path with a
?separator. Parameters should be in the same order as sent. - Secret key — Confirm you are using the correct secret key for the environment (live vs. test).
- Encoding — The signature must be lowercase hex-encoded.
Security Best Practices
- Never expose your secret key client-side. All HMAC signing should happen on your server. Mobile and browser apps should proxy through your backend.
- Rotate keys periodically. Generate a new secret key from the dashboard and update your server configuration. The old key remains valid until you deactivate it.
- Use test keys during development. Test keys (
sk_test_*) let you develop without incurring charges or affecting production data. - Validate webhooks. ProofAge signs outbound webhook payloads. Always verify the webhook signature before trusting the payload.
- Restrict key access. Limit who on your team can view or rotate API keys. Use environment variables or a secrets manager — never commit keys to version control.
- Monitor for unauthorized usage. Review your API usage in the dashboard regularly. Unexpected spikes may indicate a compromised key.
TIP
For a comprehensive overview of HMAC signing, multi-key behavior, key rotation, and webhook verification in one place, see Endpoint Security.