> Toden documentation. For the full index fetch /llms.txt. Every page is available as Markdown by appending .md to its URL. Identity verification uses /api/kyc, Risk Intelligence uses /api/v1; they are separate contracts with different error shapes. Never invent an endpoint, field or enum value: read the API reference page for the operation first.

# Compare two photos of a face

POST https://sandbox-api.toden.co/api/kyc/face/compare
Content-Type: application/json

Compares two photos and answers, in the same call, whether they show the same person. The verdict is judged against the pass mark and review floor recorded for your organisation. There is no per-request threshold, so apply a stricter bar by branching on `confidence`.

**Sandbox.** Sandbox keys always return a simulated verdict (a `match` unless `sandboxOutcome` asks for another), placed inside your own thresholds. Sandbox checks are free.

**Billing.** Production checks are charged per comparison. A `no_match` is charged, because the check ran and answered. When the comparison produces no answer the charge is refunded and the response is `502 no_result`, which also carries the recorded `faceCheck` beside the error fields.

**Error codes**

| `error` | Status | Meaning |
|---|---|---|
| `invalid_request` | 400 | The body failed validation, for example `consent` is not true or a photo was sent in two forms or none. |
| `same_photo` | 400 | Both sides are the same media id or the same inline image. |
| `media_unreadable` | 400 | One of the uploaded photos could not be read. Send both again. |
| `invalid_image` | 400 | An inline image is not an https URL, a data URI or base64, or is too short to be a photo. |
| `unsupported_media_type` | 400 | The bytes are not a JPEG, PNG or WebP photo. The format is read from the bytes. |
| `image_too_large` | 400 | The photo is larger than 25 MB. |
| `image_url_not_allowed` | 400 | The image URL is not http(s), or resolves to a non-public address. |
| `image_fetch_failed` | 400 | The image URL could not be fetched, returned nothing, or redirected too many times. |
| `insufficient_credits` | 402 | Your balance cannot cover the check. |
| `spot_checks_disabled` | 403 | Face checks are not enabled for your organisation. |
| `secret_key_required` | 403 | A publishable key was used. This endpoint accepts secret keys only. |
| `environment_mismatch` | 403 | The key belongs to an environment this deployment does not serve. |
| `ip_not_allowed` | 403 | The key has an IP allowlist and the calling address is not on it. |
| `organization_suspended` | 403 | The organisation is suspended. The response carries `reason` and `suspendedAt`. |
| `business_not_approved` | 403 | A live key was used before the business was approved for production. |
| `organization_not_found` | 404 | The organisation behind the key no longer exists. |
| `rate_limited` | 429 | The hourly face check limit was reached. Retry later. |
| `no_result` | 502 | The comparison could not be completed. Nothing was charged. |

A missing or unknown key answers `401`, where `error` is a short sentence rather than a code.

Requires a secret (`sk_`) key. A publishable key receives `403 secret_key_required`.

Reference: https://docs.toden.co/api-reference/verification/face-match/compare-faces

## Authentication

- `Authorization` header (bearer token, required) — Send the API key as a Bearer token. Each operation states whether a publishable key is enough or a secret key is required.

## Servers

- `https://sandbox-api.toden.co` (Sandbox, default)
- `https://api.toden.co` (Production)

## Request

### Body (application/json)

This endpoint expects a FaceCompareRequest.

- `consent` (true, required) — Must be true. You attest that the people shown consented to the comparison.
- `imageA` (string, optional) — The first photo, inline. An inline image is an `https://` URL that is fetched for you, a `data:` URI, or base64-encoded bytes. JPEG, PNG and WebP are accepted, up to 25 MB by URL. Base64 travels inside the JSON body, which is capped at 10 MB.
- `imageB` (string, optional) — The second photo, inline, in the same forms as `imageA`.
- `mediaAId` (string, optional) — The first photo as a `mediaId` from the face upload endpoint, instead of `imageA`.
- `mediaBId` (string, optional) — The second photo as a `mediaId` from the face upload endpoint, instead of `imageB`.
- `sandboxOutcome` (enum, optional) — Picks the simulated verdict outside production. Sandbox keys are always simulated and default to `match`. Development keys run the real comparison unless an outcome is given, and `live` asks for the real comparison there. Ignored on live keys.
  - Allowed values: `match`, `review`, `no_match`, `no_result`, `live`

## Response

### 200

The comparison ran and returned a verdict.

- `faceCheck` (FaceCheck, required)

## Errors

### 400 Bad Request Error

The request body, query or path was invalid.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

### 401 Unauthorized Error

The Authorization header is missing, or the API key is unknown or revoked.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

### 402 Payment Required Error

The organisation does not have enough credit for this request.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

### 403 Forbidden Error

The key may not call this endpoint, or the organisation may not use this feature.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

### 404 Not Found Error

The resource does not exist for this organisation and environment.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

### 429 Too Many Requests Error

A rate limit was reached. Retry after a short wait.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

### 502 Bad Gateway Error

An upstream provider failed. Nothing was charged.

- `error` (string, required) — A stable machine-readable code such as `invalid_input`. Authentication failures (401) carry a short sentence here instead of a code.
- `message` (string, optional) — A human-readable explanation, safe to log. The wording may change.

## Types

### FaceCheck

- `id` (string, required) — The face check id.
- `match` (boolean, required, nullable) — Whether the two photos were judged to show the same person. Null when there was no result.
- `confidence` (double, required, nullable) — The similarity score on a 0 to 100 scale. Null when there was no result.
- `threshold` (double, required, nullable) — The pass mark that judged this check, recorded at the time it ran.
- `reviewThreshold` (double, required, nullable) — The review floor that judged this check, recorded at the time it ran.
- `chargedAmount` (string, required, nullable) — What the check cost in USD, as a decimal string. Null outside production and when nothing was charged.
- `createdAt` (datetime, required) — When the check ran.
- `verdict` (enum, required) — The verdict against your organisation's thresholds. `match` is at or above your pass mark, `review` sits between your review floor and pass mark, `no_match` is below the review floor, and `no_result` means the comparison produced no answer.
  - Allowed values: `match`, `review`, `no_match`, `no_result`

## Examples

**Request**

```json
{
  "consent": true,
  "imageA": "https://cdn.example.com/enrolment/selfie-42.jpg",
  "imageB": "https://cdn.example.com/login/selfie-42-today.jpg"
}
```

**Response**

```json
{
  "faceCheck": {
    "id": "cmt8s0a4p0001qw9k2m7f3fc1",
    "match": true,
    "confidence": 91,
    "threshold": 75,
    "reviewThreshold": 65,
    "chargedAmount": "0.0300",
    "createdAt": "2026-09-12T10:00:00.000Z",
    "verdict": "match"
  }
}
```

**SDK Code**

```python
import requests

url = "https://sandbox-api.toden.co/api/kyc/face/compare"

payload = {
    "consent": True,
    "imageA": "https://cdn.example.com/enrolment/selfie-42.jpg",
    "imageB": "https://cdn.example.com/login/selfie-42-today.jpg"
}
headers = {
    "Authorization": "Bearer <apiKey>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript
const url = 'https://sandbox-api.toden.co/api/kyc/face/compare';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <apiKey>', 'Content-Type': 'application/json'},
  body: '{"consent":true,"imageA":"https://cdn.example.com/enrolment/selfie-42.jpg","imageB":"https://cdn.example.com/login/selfie-42-today.jpg"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://sandbox-api.toden.co/api/kyc/face/compare"

	payload := strings.NewReader("{\n  \"consent\": true,\n  \"imageA\": \"https://cdn.example.com/enrolment/selfie-42.jpg\",\n  \"imageB\": \"https://cdn.example.com/login/selfie-42-today.jpg\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <apiKey>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby
require 'uri'
require 'net/http'

url = URI("https://sandbox-api.toden.co/api/kyc/face/compare")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <apiKey>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"consent\": true,\n  \"imageA\": \"https://cdn.example.com/enrolment/selfie-42.jpg\",\n  \"imageB\": \"https://cdn.example.com/login/selfie-42-today.jpg\"\n}"

response = http.request(request)
puts response.read_body
```

```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://sandbox-api.toden.co/api/kyc/face/compare")
  .header("Authorization", "Bearer <apiKey>")
  .header("Content-Type", "application/json")
  .body("{\n  \"consent\": true,\n  \"imageA\": \"https://cdn.example.com/enrolment/selfie-42.jpg\",\n  \"imageB\": \"https://cdn.example.com/login/selfie-42-today.jpg\"\n}")
  .asString();
```

```php
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://sandbox-api.toden.co/api/kyc/face/compare', [
  'body' => '{
  "consent": true,
  "imageA": "https://cdn.example.com/enrolment/selfie-42.jpg",
  "imageB": "https://cdn.example.com/login/selfie-42-today.jpg"
}',
  'headers' => [
    'Authorization' => 'Bearer <apiKey>',
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();
```

```csharp
using RestSharp;

var client = new RestClient("https://sandbox-api.toden.co/api/kyc/face/compare");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <apiKey>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"consent\": true,\n  \"imageA\": \"https://cdn.example.com/enrolment/selfie-42.jpg\",\n  \"imageB\": \"https://cdn.example.com/login/selfie-42-today.jpg\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift
import Foundation

let headers = [
  "Authorization": "Bearer <apiKey>",
  "Content-Type": "application/json"
]
let parameters = [
  "consent": true,
  "imageA": "https://cdn.example.com/enrolment/selfie-42.jpg",
  "imageB": "https://cdn.example.com/login/selfie-42-today.jpg"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://sandbox-api.toden.co/api/kyc/face/compare")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```