Skip to content

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:

HeaderPurposeRequired
X-API-KeyIdentifies your workspace. Obtained from the dashboard.Always
X-HMAC-SignatureHMAC-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 / testing

Store 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 + body

Where:

  1. METHOD — The HTTP method in uppercase: GET, POST, PUT, DELETE, etc.
  2. path — The request path including query string if present. For example: /v1/verifications or /v1/consent?locale=en.
  3. body — The raw request body exactly as sent. For GET or 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 + body

Then 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' + sortedFileHashes

Where:

  1. METHOD + path — Same as standard requests.
  2. sortedFormFields — All non-file form fields, canonicalized:
    • Sort field names alphabetically (recursively for nested fields).
    • Serialize using http_build_query with RFC 3986 encoding.
    • Example: external_id=user_123&type=selfie
  3. 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

javascript
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

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
<?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 StatusError CodeDescription
401MISSING_API_KEYThe X-API-Key header was not included in the request.
401INVALID_API_KEYThe API key does not match any workspace. Check for typos or confirm the key is still active.
403WORKSPACE_SUSPENDEDThe workspace associated with the API key has been suspended. Contact support.
401NO_SECRET_KEYSThe workspace has no secret keys. Generate one from the dashboard.
401MISSING_SIGNATUREThe X-HMAC-Signature header is required for this endpoint but was not provided.
401INVALID_SIGNATUREThe HMAC signature does not match the expected value. See troubleshooting below.

All error responses follow this format:

json
{
  "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:

  1. Method case — The HTTP method in the canonical string must be uppercase (POST, not post).
  2. 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.
  3. Path — Use the path without the domain: /v1/verifications, not https://api.proofage.com/v1/verifications.
  4. Query string — If present, include it after the path with a ? separator. Parameters should be in the same order as sent.
  5. Secret key — Confirm you are using the correct secret key for the environment (live vs. test).
  6. 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.

ProofAge Developer Documentation