> ## Documentation Index
> Fetch the complete documentation index at: https://docs.proofage.net/llms.txt
> Use this file to discover all available pages before exploring further.

# API authentication

> The public key identifies your workspace; an HMAC signature made with the secret key proves the request is yours.

<Tip>
  The [server SDKs](/integration/sdks) sign every request for you. Read on if you call the API without one.
</Tip>

## Keys

Every workspace has two kinds of keys, shown in the console on the workspace's page.

| | Public key | Secret key |
| - | - | - |
| Looks like | `pk_test_…`, `pk_live_…` | `sk_test_…`, `sk_live_…` |
| Sent as | The `X-API-Key` header | Never sent: it keys the signature |
| Where it may live | Backend and browser (the Browser SDK needs it) | Backend only |
| How many | One per workspace | Up to five; the active one also signs webhooks |

A workspace can hold several secret keys so you can rotate without downtime: any of them signs requests. See [Workspaces and keys](/console/workspaces-and-keys).

## Signing a request

Send two headers on every request:

```http theme={null}
X-API-Key: pk_live_...
X-HMAC-Signature: 3f1c...e9
```

The signature is the lowercase hex HMAC-SHA256 of a canonical string, keyed with a secret key:

```
canonical = METHOD + path [+ "?" + query] + raw body
signature = hex(HMAC-SHA256(canonical, secret key))
```

* `METHOD` is upper case: `POST`, `GET`.
* `path` includes the version and no host: `/v1/verifications`.
* The query string, when there is one, is appended after a `?`, with its parameters sorted by name and encoded as RFC 3986 (spaces as `%20`, a comma as `%2C`). `GET /v1/verifications?status=approved,declined&limit=20` is signed as `GET/v1/verifications?limit=20&status=approved%2Cdeclined`.
* The body is the exact bytes you send. A `GET` has an empty body.

For example, creating a verification:

```http theme={null}
POST /v1/verifications
Content-Type: application/json

{"external_id":"user_12345"}
```

is signed over:

```
POST/v1/verifications{"external_id":"user_12345"}
```

### File uploads

A `multipart/form-data` request, such as a media upload, is not signed over its raw body. Its canonical string has three lines:

```
METHOD + path [+ "?" + query]
form fields, sorted by name and encoded as RFC 3986 (spaces as %20)
SHA-256 of each file in hex, sorted and joined with commas
```

For a selfie upload with the single field `type=selfie`:

```
POST/v1/verifications/550e8400-e29b-41d4-a716-446655440000/media
type=selfie
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

Nested fields are sorted at every level. The signature is the hex HMAC-SHA256 of that string with the secret key, as for any other request.

## Code

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from 'node:crypto';
  import fs from 'node:fs';

  const SECRET_KEY = process.env.PROOFAGE_SECRET_KEY;

  export function signRequest(method, path, body = '') {
    return crypto.createHmac('sha256', SECRET_KEY)
      .update(method.toUpperCase() + path + body)
      .digest('hex');
  }

  // RFC 3986, as the server encodes: spaces become %20, and !'()* are escaped too.
  const rfc3986 = (value) => encodeURIComponent(value)
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

  export function signMultipartRequest(method, path, fields, filePaths) {
    const serializedFields = Object.keys(fields).sort()
      .map((key) => rfc3986(key) + '=' + rfc3986(String(fields[key])))
      .join('&');
    const fileHashes = filePaths
      .map((file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'))
      .sort()
      .join(',');

    return crypto.createHmac('sha256', SECRET_KEY)
      .update(method.toUpperCase() + path + '\n' + serializedFields + '\n' + fileHashes)
      .digest('hex');
  }

  signRequest('POST', '/v1/verifications', '{"external_id":"user_12345"}');
  signMultipartRequest('POST', '/v1/verifications/550e8400-e29b-41d4-a716-446655440000/media',
    { type: 'selfie' }, ['./selfie.jpg']);
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  from urllib.parse import quote, urlencode

  SECRET_KEY = os.environ["PROOFAGE_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, fields: dict, file_paths: list[str]) -> str:
      # RFC 3986, as the server encodes: only letters, digits and - _ . ~ stay bare.
      serialized = urlencode(dict(sorted(fields.items())), quote_via=lambda v, *_: quote(v, safe=""))
      hashes = sorted(hashlib.sha256(open(p, "rb").read()).hexdigest() for p in file_paths)
      canonical = method.upper() + path + "\n" + serialized + "\n" + ",".join(hashes)
      return hmac.new(SECRET_KEY.encode(), canonical.encode(), hashlib.sha256).hexdigest()


  sign_request("POST", "/v1/verifications", '{"external_id":"user_12345"}')
  sign_multipart_request("POST", "/v1/verifications/550e8400-e29b-41d4-a716-446655440000/media",
                         {"type": "selfie"}, ["./selfie.jpg"])
  ```

  ```php PHP theme={null}
  <?php

  function signRequest(string $method, string $path, string $body = ''): string
  {
      return hash_hmac('sha256', strtoupper($method) . $path . $body, getenv('PROOFAGE_SECRET_KEY'));
  }

  function signMultipartRequest(string $method, string $path, array $fields, array $filePaths): string
  {
      ksort($fields);
      $serialized = http_build_query($fields, '', '&', PHP_QUERY_RFC3986);
      $hashes = array_map(fn (string $file) => hash_file('sha256', $file), $filePaths);
      sort($hashes);

      $canonical = strtoupper($method) . $path . "\n" . $serialized . "\n" . implode(',', $hashes);

      return hash_hmac('sha256', $canonical, getenv('PROOFAGE_SECRET_KEY'));
  }

  signRequest('POST', '/v1/verifications', '{"external_id":"user_12345"}');
  signMultipartRequest('POST', '/v1/verifications/550e8400-e29b-41d4-a716-446655440000/media',
      ['type' => 'selfie'], ['./selfie.jpg']);
  ```
</CodeGroup>

The multipart helpers sort only the top level of `fields`; if you send nested form fields, sort each level the same way.

## Creating without a signature

`POST /v1/verifications` also accepts a request with only `X-API-Key`, so a page can start a verification with the public key alone. Such a request cannot tie the verification to your user: the API drops `external_id` and `callback_url` from it. Create verifications on your backend, signed, whenever you need to know whose verification it is. Every other endpoint requires the signature.

## Authentication errors

| HTTP | Code | Meaning |
| - | - | - |
| 401 | `MISSING_API_KEY` | No `X-API-Key` header. |
| 401 | `INVALID_API_KEY` | The key matches no workspace. |
| 401 | `NO_SECRET_KEYS` | The workspace has no secret key; create one in the console. |
| 401 | `MISSING_SIGNATURE` | The endpoint needs `X-HMAC-Signature`. |
| 401 | `INVALID_SIGNATURE` | The signature matches none of the workspace's secret keys. |
| 403 | `WORKSPACE_SUSPENDED` | The workspace is suspended. |
| 403 | `TENANT_ARCHIVED` | The account is closed. |

The body is `{"error": {"code": "…", "message": "…"}}`. All codes are on [Errors](/integration/error-handling).

## When the signature does not match

1. Sign the exact bytes you send. Serialise the JSON once, sign that string, and send that string; a client that re-encodes the body after signing changes it.
2. Use the path with `/v1` and without the host.
3. Include the query string, with its `?`, sorted by parameter name.
4. Use a secret key of the same workspace as the public key: a test key does not sign for a live workspace.
5. Send the signature as lowercase hex.

## Keep the secret key secret

* Sign on your server only. Browser and mobile code should call your backend, which calls ProofAge.
* Keep keys in environment variables or a secrets manager, never in source control.
* Rotate by adding a new secret key, deploying it, then deleting the old one. See [Workspaces and keys](/console/workspaces-and-keys).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.