# Contrato.Cloud KYC

Revision: ab4c5635b7f0f446ba6cfcf3607f9fb94636e233074478aed2ea1a9c23f83ead

# KYC API: from identity checks to results

Source: https://contrato.cloud/en/developers/kyc/overview

## One workflow, three responsibilities

Your server creates a transaction through the API. The person completes identity verification in the hosted journey using the returned link. Your application reads the result and receives events to continue its business process.

1. **Create:** call `POST /v1/face-auth/transactions` with `X-API-Key` from your backend.
2. **Hand over:** use the returned URL; never send your key to the person’s browser.
3. **Track:** associate `transaction_id` with your process, verify webhooks and reconcile state with GET.

## What the result means

Identity verification is one step in your workflow. An approved identity does not automatically approve credit, a purchase, an account or a person’s authority to represent an organization. Your system remains responsible for commercial rules, unresolved cases and the decision to proceed.

`status` describes the operational stage; `decision` is `pending`, `approved` or `rejected`. Do not interpret an intermediate step as approval. Expired journeys also require a specific outcome in your application.

## Choose a starting point

Use the [local simulator](/en/developers/kyc/simulator) to understand the workflow without credentials or personal data. To integrate your backend, follow the [quickstart](/en/developers/kyc/quickstart) and consult the [API reference](/en/developers/kyc/reference).

Examples use synthetic data and a reserved documentation address. Your API base URL, credentials and environment access are established during integration enablement.


---

# Integrate your first KYC journey

Source: https://contrato.cloud/en/developers/kyc/quickstart

## Before running a request

These commands are synthetic examples. `api.example.invalid` is not a test environment and does not resolve to a real service. Replace the base only with the confirmed URL for your enabled environment and keep the key on your backend. To explore without network requests or credentials, open the [simulator](/en/developers/kyc/simulator).

## 1. Create a transaction

Choose an idempotency key that stays unchanged for the same creation attempt. The CPF below is a fictitious documentation value.

Create a KYC transaction

### cURL

```bash
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
set -eu
: "${KYC_API_BASE_URL:?Set authorized base}" "${KYC_API_KEY:?Set API key}"
: "${KYC_IDEMPOTENCY_KEY:?Persist the key for this creation intent}"
curl --fail-with-body --max-time 15 --request POST \
  "${KYC_API_BASE_URL%/}/v1/face-auth/transactions" \
  --header "X-API-Key: ${KYC_API_KEY}" \
  --header "Idempotency-Key: ${KYC_IDEMPOTENCY_KEY}" \
  --header 'Content-Type: application/json' \
  --data-binary '{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}'
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### JavaScript / Node.js

```javascript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name) {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions", {
  method: "POST",
  headers: { "X-API-Key": required("KYC_API_KEY"), "Content-Type": "application/json", "Idempotency-Key": required("KYC_IDEMPOTENCY_KEY") },
  body: "{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}",
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 201) throw new Error("KYC HTTP " + response.status);
const transaction = await response.json();
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### TypeScript / Node.js

```typescript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions", {
  method: "POST",
  headers: { "X-API-Key": required("KYC_API_KEY"), "Content-Type": "application/json", "Idempotency-Key": required("KYC_IDEMPOTENCY_KEY") },
  body: "{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}",
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 201) throw new Error("KYC HTTP " + response.status);
const transaction: unknown = await response.json();
// Validate the response against the curated OpenAPI before using it.
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Python

```python
import json
import os
from urllib.parse import quote
from urllib.request import Request, urlopen
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
base = os.environ["KYC_API_BASE_URL"].rstrip("/")
request = Request(base + "/v1/face-auth/transactions",
    method="POST",
    headers={"X-API-Key": os.environ["KYC_API_KEY"], "Content-Type": "application/json",
             "Idempotency-Key": os.environ["KYC_IDEMPOTENCY_KEY"]},
    data="{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}".encode("utf-8"),
)
with urlopen(request, timeout=15) as response:
    if response.status != 201:
        raise RuntimeError("KYC HTTP " + str(response.status))
    transaction = json.load(response)
    print("HTTP " + str(response.status))
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### PHP

```php
<?php
// PHP 8+, standard HTTP streams. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '') throw new RuntimeException("Missing configuration: " . $name);
    return $value;
}
$url = rtrim(required('KYC_API_BASE_URL'), '/') . '/v1/face-auth/transactions';
$headers = ['X-API-Key: ' . required('KYC_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . required('KYC_IDEMPOTENCY_KEY')];
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers),
    'timeout' => 15, 'ignore_errors' => true, 'content' => '{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}']]);
$body = file_get_contents($url, false, $context);
if ($body === false) throw new RuntimeException('KYC transport failure');
if (!preg_match('/^HTTP\/\S+ ([0-9]{3})/', $http_response_header[0] ?? '', $matches)) throw new RuntimeException('Invalid HTTP response');
$status = (int) $matches[1];
if ($status !== 201) throw new RuntimeException('KYC HTTP ' . $status);
$transaction = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
echo 'HTTP ' . $status . PHP_EOL;
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### C# / .NET

```csharp
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
// .NET 8+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : throw new Exception("Missing configuration: " + name);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
using var request = new HttpRequestMessage(HttpMethod.Post, Required("KYC_API_BASE_URL").TrimEnd('/') + "/v1/face-auth/transactions");
request.Headers.Add("X-API-Key", Required("KYC_API_KEY"));
request.Headers.Add("Idempotency-Key", Required("KYC_IDEMPOTENCY_KEY"));
request.Content = new StringContent("{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
if ((int)response.StatusCode != 201) throw new Exception("KYC HTTP " + (int)response.StatusCode);
using var transaction = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine("HTTP " + (int)response.StatusCode);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Java

```java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
// Java 17+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
class Example {
  static String required(String name) {
    String value = System.getenv(name);
    if (value == null || value.isEmpty()) throw new IllegalStateException("Missing configuration: " + name);
    return value;
  }
  public static void main(String[] args) throws Exception {
    String base = required("KYC_API_BASE_URL").replaceAll("/+$", "");
    String url = base + "/v1/face-auth/transactions";
    var builder = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15))
        .header("X-API-Key", required("KYC_API_KEY"));
    builder.header("Idempotency-Key", required("KYC_IDEMPOTENCY_KEY"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}", StandardCharsets.UTF_8));
    var response = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
    if (response.statusCode() != 201) throw new IllegalStateException("KYC HTTP " + response.statusCode());
    System.out.println("HTTP " + response.statusCode());
    // Parse response.body() with your application's JSON parser and validate against OpenAPI.
    // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
  }
}
```

### Go

```go
package main
import (
  "encoding/json"
  "fmt"
  "net/http"
  "net/url"
  "os"
  "strings"
  "time"
)
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
func required(name string) string {
  value := os.Getenv(name); if value == "" { panic("Missing configuration: " + name) }; return value
}
func main() {
  _ = url.PathEscape // Used by the GET variant.
  endpoint := strings.TrimRight(required("KYC_API_BASE_URL"), "/") + "/v1/face-auth/transactions"
  request, err := http.NewRequest("POST", endpoint, strings.NewReader(`{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}`)); if err != nil { panic(err) }
  request.Header.Set("X-API-Key", required("KYC_API_KEY"))
  request.Header.Set("Content-Type", "application/json")
  request.Header.Set("Idempotency-Key", required("KYC_IDEMPOTENCY_KEY"))
  response, err := (&http.Client{Timeout: 15*time.Second}).Do(request); if err != nil { panic(err) }; defer response.Body.Close()
  if response.StatusCode != 201 { panic(fmt.Sprintf("KYC HTTP %d", response.StatusCode)) }
  var transaction map[string]any
  if err := json.NewDecoder(response.Body).Decode(&transaction); err != nil { panic(err) }
  fmt.Printf("HTTP %d\n", response.StatusCode)
  // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
}
```

## 2. Hand over the journey

The POST returns `201` and a `transaction_id`. Store that identifier alongside your `external_id`. Direct the person to the returned hosted URL in `links.webview_url`; do not construct journey URLs from transaction identifiers.

## 3. Read the result

Read and reconcile a KYC transaction

### cURL

```bash
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
set -eu
: "${KYC_API_BASE_URL:?Set authorized base}" "${KYC_API_KEY:?Set API key}"
: "${KYC_TRANSACTION_ID:?Use the returned transaction_id}"
curl --fail-with-body --max-time 15 --request GET \
  "${KYC_API_BASE_URL%/}/v1/face-auth/transactions/${KYC_TRANSACTION_ID}" \
  --header "X-API-Key: ${KYC_API_KEY}"
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### JavaScript / Node.js

```javascript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name) {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions" + "/" + encodeURIComponent(required("KYC_TRANSACTION_ID")), {
  method: "GET",
  headers: { "X-API-Key": required("KYC_API_KEY") },
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 200) throw new Error("KYC HTTP " + response.status);
const transaction = await response.json();
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### TypeScript / Node.js

```typescript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions" + "/" + encodeURIComponent(required("KYC_TRANSACTION_ID")), {
  method: "GET",
  headers: { "X-API-Key": required("KYC_API_KEY") },
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 200) throw new Error("KYC HTTP " + response.status);
const transaction: unknown = await response.json();
// Validate the response against the curated OpenAPI before using it.
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Python

```python
import json
import os
from urllib.parse import quote
from urllib.request import Request, urlopen
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
base = os.environ["KYC_API_BASE_URL"].rstrip("/")
request = Request(base + "/v1/face-auth/transactions" + "/" + quote(os.environ["KYC_TRANSACTION_ID"], safe=""),
    method="GET",
    headers={"X-API-Key": os.environ["KYC_API_KEY"]},
)
with urlopen(request, timeout=15) as response:
    if response.status != 200:
        raise RuntimeError("KYC HTTP " + str(response.status))
    transaction = json.load(response)
    print("HTTP " + str(response.status))
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### PHP

```php
<?php
// PHP 8+, standard HTTP streams. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '') throw new RuntimeException("Missing configuration: " . $name);
    return $value;
}
$url = rtrim(required('KYC_API_BASE_URL'), '/') . '/v1/face-auth/transactions' . '/' . rawurlencode(required('KYC_TRANSACTION_ID'));
$headers = ['X-API-Key: ' . required('KYC_API_KEY')];
$context = stream_context_create(['http' => ['method' => 'GET', 'header' => implode("\r\n", $headers),
    'timeout' => 15, 'ignore_errors' => true]]);
$body = file_get_contents($url, false, $context);
if ($body === false) throw new RuntimeException('KYC transport failure');
if (!preg_match('/^HTTP\/\S+ ([0-9]{3})/', $http_response_header[0] ?? '', $matches)) throw new RuntimeException('Invalid HTTP response');
$status = (int) $matches[1];
if ($status !== 200) throw new RuntimeException('KYC HTTP ' . $status);
$transaction = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
echo 'HTTP ' . $status . PHP_EOL;
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### C# / .NET

```csharp
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
// .NET 8+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : throw new Exception("Missing configuration: " + name);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
using var request = new HttpRequestMessage(HttpMethod.Get, Required("KYC_API_BASE_URL").TrimEnd('/') + "/v1/face-auth/transactions" + "/" + Uri.EscapeDataString(Required("KYC_TRANSACTION_ID")));
request.Headers.Add("X-API-Key", Required("KYC_API_KEY"));
using var response = await client.SendAsync(request);
if ((int)response.StatusCode != 200) throw new Exception("KYC HTTP " + (int)response.StatusCode);
using var transaction = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine("HTTP " + (int)response.StatusCode);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Java

```java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
// Java 17+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
class Example {
  static String required(String name) {
    String value = System.getenv(name);
    if (value == null || value.isEmpty()) throw new IllegalStateException("Missing configuration: " + name);
    return value;
  }
  public static void main(String[] args) throws Exception {
    String base = required("KYC_API_BASE_URL").replaceAll("/+$", "");
    String url = base + "/v1/face-auth/transactions" + "/" + URLEncoder.encode(required("KYC_TRANSACTION_ID"), StandardCharsets.UTF_8).replace("+", "%20");
    var builder = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15))
        .header("X-API-Key", required("KYC_API_KEY"));
    builder.GET();
    var response = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
    if (response.statusCode() != 200) throw new IllegalStateException("KYC HTTP " + response.statusCode());
    System.out.println("HTTP " + response.statusCode());
    // Parse response.body() with your application's JSON parser and validate against OpenAPI.
    // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
  }
}
```

### Go

```go
package main
import (
  "encoding/json"
  "fmt"
  "net/http"
  "net/url"
  "os"
  "strings"
  "time"
)
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
func required(name string) string {
  value := os.Getenv(name); if value == "" { panic("Missing configuration: " + name) }; return value
}
func main() {
  _ = url.PathEscape // Used by the GET variant.
  endpoint := strings.TrimRight(required("KYC_API_BASE_URL"), "/") + "/v1/face-auth/transactions" + "/" + url.PathEscape(required("KYC_TRANSACTION_ID"))
  request, err := http.NewRequest("GET", endpoint, nil); if err != nil { panic(err) }
  request.Header.Set("X-API-Key", required("KYC_API_KEY"))
  response, err := (&http.Client{Timeout: 15*time.Second}).Do(request); if err != nil { panic(err) }; defer response.Body.Close()
  if response.StatusCode != 200 { panic(fmt.Sprintf("KYC HTTP %d", response.StatusCode)) }
  var transaction map[string]any
  if err := json.NewDecoder(response.Body).Decode(&transaction); err != nil { panic(err) }
  fmt.Printf("HTTP %d\n", response.StatusCode)
  // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
}
```

Read `status`, `decision` and `decision_reason` together. A `pending` decision does not allow your application to treat the flow as approved. The request must use a key for the same Tenant and environment as the transaction.

## Next step

Implement [webhooks](/en/developers/kyc/webhooks-and-events) with [HMAC verification](/en/developers/kyc/verify-hmac-signature) and [reconciliation](/en/developers/kyc/redelivery-and-reconciliation). Before production, cover rejection, expiration, manual review, duplicates and temporary connection loss.


---

# Integrate KYC with your AI agent

Source: https://contrato.cloud/en/developers/integrate-with-ai

## Prepare your KYC integration with an agent

Choose a language and copy the instructions below into your development agent. The context includes public guides, complete examples and the contract reference. Prepare with synthetic data; the example `.invalid` base is not an execution environment.

## Integration prompt and context

### cURL

```text
Implement a Contrato.Cloud KYC backend integration in cURL, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=curl)
MCP tools: search_docs, read_doc, get_example

```

### JavaScript / Node.js

```text
Implement a Contrato.Cloud KYC backend integration in JavaScript / Node.js, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=javascript)
MCP tools: search_docs, read_doc, get_example

```

### TypeScript / Node.js

```text
Implement a Contrato.Cloud KYC backend integration in TypeScript / Node.js, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=typescript)
MCP tools: search_docs, read_doc, get_example

```

### Python

```text
Implement a Contrato.Cloud KYC backend integration in Python, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=python)
MCP tools: search_docs, read_doc, get_example

```

### PHP

```text
Implement a Contrato.Cloud KYC backend integration in PHP, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=php)
MCP tools: search_docs, read_doc, get_example

```

### C# / .NET

```text
Implement a Contrato.Cloud KYC backend integration in C# / .NET, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=csharp)
MCP tools: search_docs, read_doc, get_example

```

### Java

```text
Implement a Contrato.Cloud KYC backend integration in Java, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=java)
MCP tools: search_docs, read_doc, get_example

```

### Go

```text
Implement a Contrato.Cloud KYC backend integration in Go, using only the public documentation linked below.
1. Read the context and OpenAPI before writing code. Cite the pages used; ask for clarification when a field or behavior is undocumented. Do not invent a SIGN signature API, Admin routes or private Portal endpoints.
2. Use https://api.example.invalid as a synthetic base URL. Before any real execution, request the authorized environment base and load X-API-Key only from backend environment variables. Never put keys in the frontend, prompt, URL, logs or repository.
3. Create transactions with POST /v1/face-auth/transactions and a stable Idempotency-Key per logical operation. Persist transaction_id and external_id. Redirect the person using the returned links.webview_url; do not construct the journey URL.
4. Read GET /v1/face-auth/transactions/{transaction_id}. Handle status, decision and decision_reason together. pending and intermediate steps remain pending; manual_review_pending waits for review; approved requires decision=approved; rejected requires rejection handling; expired ends or restarts according to policy. Expiration does not imply decision=rejected.
5. Preserve exact raw body bytes in the webhook receiver. Validate X-Face-Auth-Timestamp and X-Face-Auth-Signature (sha256=<hex>) using HMAC SHA-256 over timestamp + '.' + raw_body, constant-time comparison and a time window. Only then parse JSON and validate the envelope.
6. Deduplicate event_id in durable storage, match the header event identifier to the body, and make business effects idempotent. Reconcile out-of-order events and missed deliveries by reading the current transaction; never downgrade a newer decision using an older event.
7. Deliver configuration, error handling, synthetic fixture tests and execution instructions. Cover approval, rejection, pending, expiration, manual review, duplicates, altered body, Unicode and stale timestamp. Do not execute real API calls or process personal data without explicit authorization.
8. cURL covers HTTP only. If cURL is preferred, use a supported backend language for the receiver/HMAC and explain the choice. The MCP below only reads public documentation: it does not create transactions, sign documents or charge.

Context: https://contrato.cloud/downloads/ai/en/context.md
OpenAPI: https://contrato.cloud/downloads/kyc/openapi.json
Index: https://contrato.cloud/llms.txt
MCP (Streamable HTTP): https://contrato.cloud/api/mcp
MCP prompt: integrate_kyc (locale=en, language=go)
MCP tools: search_docs, read_doc, get_example

```

## Validate before execution

Review the generated code, configure the authorized base in your backend, and test idempotency, HMAC, reconciliation, expiration and manual review. Read the [production checklist](/en/developers/kyc/go-live). AI documentation and MCP do not receive KYC keys or customer data.


---

# Authentication and API key protection

Source: https://contrato.cloud/en/developers/kyc/authentication

## Server-to-server authentication

The B2B API uses the `X-API-Key` header. The key determines the Tenant and environment; sending another organization’s identifier cannot change that scope. API credentials are separate from Portal login and browser session tokens.

```http
X-API-Key: REPLACE_ON_YOUR_SERVER
```

## Protect the secret

- Store the key in a secrets manager or protected server configuration.
- Never include it in public JavaScript, URLs, distributed applications, repositories or support tickets.
- Log only necessary operational data. Redact headers and personal data.
- Separate credentials and configuration by environment. Grant only the permissions your integration needs.

If you suspect exposure, stop using the compromised key, coordinate revocation and replacement with the environment owner and confirm that consumers have switched to the new credential. Rotation should not require changing the verified person’s application.

## Diagnose access errors

`401` can mean a missing, invalid, revoked or environment-incompatible key. Depending on the operation, `403` can also indicate a disabled environment, insufficient permissions or an unauthorized IP. Do not retry these failures indefinitely.

Downloads require separate authorization; access to a transaction does not automatically grant access to its files. See [documents and evidence](/en/developers/kyc/documents-and-evidence) and the error codes in the [reference](/en/developers/kyc/reference).


---

# KYC environments and integration configuration

Source: https://contrato.cloud/en/developers/kyc/environments

## Every environment has its own context

Configure the API base URL, key and webhook receiver as a set. A transaction belongs to the Tenant and environment resolved from its credential. An identifier valid in another environment does not grant access and may return `404`.

```text
KYC_API_BASE_URL=https://api.example.invalid
KYC_API_KEY=REPLACE_ON_YOUR_SERVER
KYC_WEBHOOK_SECRET=REPLACE_ON_YOUR_SERVER
```

These values are placeholders. An example URL does not prove that a service is active. Confirm official addresses and access during integration enablement.

## Separate your own systems too

Use independent configuration for databases, queues and callbacks. Include the environment in your local transaction association key. A test routine must never activate credit, release an account or change real agreements.

Use synthetic data for contract tests. When acceptance requires a real journey, agree on purpose, access and data handling with the operation’s owners. Do not send personal documents to the public simulator: it uses only fixed scenarios in the browser.

## Before switching environments

Confirm credentials, download permissions, callback configuration, HMAC secret and continuation rules. Check authorized IPs and URLs as well. Repeat rejection, expiration and reconciliation tests; a successful connection does not replace functional acceptance. Follow the [go-live checklist](/en/developers/kyc/go-live).


---

# Create an identity verification transaction

Source: https://contrato.cloud/en/developers/kyc/create-transaction

## Define the process being verified

Create a transaction with `POST /v1/face-auth/transactions`. Required fields are `country_code` and `subject`; inside `subject`, provide `identity` with `type`, `number` and `country`. Check supported countries and locales in the reference; the website’s language list is not the API’s enum list.

Create a KYC transaction

### cURL

```bash
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
set -eu
: "${KYC_API_BASE_URL:?Set authorized base}" "${KYC_API_KEY:?Set API key}"
: "${KYC_IDEMPOTENCY_KEY:?Persist the key for this creation intent}"
curl --fail-with-body --max-time 15 --request POST \
  "${KYC_API_BASE_URL%/}/v1/face-auth/transactions" \
  --header "X-API-Key: ${KYC_API_KEY}" \
  --header "Idempotency-Key: ${KYC_IDEMPOTENCY_KEY}" \
  --header 'Content-Type: application/json' \
  --data-binary '{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}'
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### JavaScript / Node.js

```javascript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name) {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions", {
  method: "POST",
  headers: { "X-API-Key": required("KYC_API_KEY"), "Content-Type": "application/json", "Idempotency-Key": required("KYC_IDEMPOTENCY_KEY") },
  body: "{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}",
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 201) throw new Error("KYC HTTP " + response.status);
const transaction = await response.json();
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### TypeScript / Node.js

```typescript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions", {
  method: "POST",
  headers: { "X-API-Key": required("KYC_API_KEY"), "Content-Type": "application/json", "Idempotency-Key": required("KYC_IDEMPOTENCY_KEY") },
  body: "{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}",
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 201) throw new Error("KYC HTTP " + response.status);
const transaction: unknown = await response.json();
// Validate the response against the curated OpenAPI before using it.
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Python

```python
import json
import os
from urllib.parse import quote
from urllib.request import Request, urlopen
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
base = os.environ["KYC_API_BASE_URL"].rstrip("/")
request = Request(base + "/v1/face-auth/transactions",
    method="POST",
    headers={"X-API-Key": os.environ["KYC_API_KEY"], "Content-Type": "application/json",
             "Idempotency-Key": os.environ["KYC_IDEMPOTENCY_KEY"]},
    data="{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}".encode("utf-8"),
)
with urlopen(request, timeout=15) as response:
    if response.status != 201:
        raise RuntimeError("KYC HTTP " + str(response.status))
    transaction = json.load(response)
    print("HTTP " + str(response.status))
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### PHP

```php
<?php
// PHP 8+, standard HTTP streams. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '') throw new RuntimeException("Missing configuration: " . $name);
    return $value;
}
$url = rtrim(required('KYC_API_BASE_URL'), '/') . '/v1/face-auth/transactions';
$headers = ['X-API-Key: ' . required('KYC_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . required('KYC_IDEMPOTENCY_KEY')];
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers),
    'timeout' => 15, 'ignore_errors' => true, 'content' => '{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}']]);
$body = file_get_contents($url, false, $context);
if ($body === false) throw new RuntimeException('KYC transport failure');
if (!preg_match('/^HTTP\/\S+ ([0-9]{3})/', $http_response_header[0] ?? '', $matches)) throw new RuntimeException('Invalid HTTP response');
$status = (int) $matches[1];
if ($status !== 201) throw new RuntimeException('KYC HTTP ' . $status);
$transaction = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
echo 'HTTP ' . $status . PHP_EOL;
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### C# / .NET

```csharp
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
// .NET 8+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : throw new Exception("Missing configuration: " + name);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
using var request = new HttpRequestMessage(HttpMethod.Post, Required("KYC_API_BASE_URL").TrimEnd('/') + "/v1/face-auth/transactions");
request.Headers.Add("X-API-Key", Required("KYC_API_KEY"));
request.Headers.Add("Idempotency-Key", Required("KYC_IDEMPOTENCY_KEY"));
request.Content = new StringContent("{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
if ((int)response.StatusCode != 201) throw new Exception("KYC HTTP " + (int)response.StatusCode);
using var transaction = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine("HTTP " + (int)response.StatusCode);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Java

```java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
// Java 17+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
class Example {
  static String required(String name) {
    String value = System.getenv(name);
    if (value == null || value.isEmpty()) throw new IllegalStateException("Missing configuration: " + name);
    return value;
  }
  public static void main(String[] args) throws Exception {
    String base = required("KYC_API_BASE_URL").replaceAll("/+$", "");
    String url = base + "/v1/face-auth/transactions";
    var builder = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15))
        .header("X-API-Key", required("KYC_API_KEY"));
    builder.header("Idempotency-Key", required("KYC_IDEMPOTENCY_KEY"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}", StandardCharsets.UTF_8));
    var response = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
    if (response.statusCode() != 201) throw new IllegalStateException("KYC HTTP " + response.statusCode());
    System.out.println("HTTP " + response.statusCode());
    // Parse response.body() with your application's JSON parser and validate against OpenAPI.
    // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
  }
}
```

### Go

```go
package main
import (
  "encoding/json"
  "fmt"
  "net/http"
  "net/url"
  "os"
  "strings"
  "time"
)
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
func required(name string) string {
  value := os.Getenv(name); if value == "" { panic("Missing configuration: " + name) }; return value
}
func main() {
  _ = url.PathEscape // Used by the GET variant.
  endpoint := strings.TrimRight(required("KYC_API_BASE_URL"), "/") + "/v1/face-auth/transactions"
  request, err := http.NewRequest("POST", endpoint, strings.NewReader(`{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}`)); if err != nil { panic(err) }
  request.Header.Set("X-API-Key", required("KYC_API_KEY"))
  request.Header.Set("Content-Type", "application/json")
  request.Header.Set("Idempotency-Key", required("KYC_IDEMPOTENCY_KEY"))
  response, err := (&http.Client{Timeout: 15*time.Second}).Do(request); if err != nil { panic(err) }; defer response.Body.Close()
  if response.StatusCode != 201 { panic(fmt.Sprintf("KYC HTTP %d", response.StatusCode)) }
  var transaction map[string]any
  if err := json.NewDecoder(response.Body).Decode(&transaction); err != nil { panic(err) }
  fmt.Printf("HTTP %d\n", response.StatusCode)
  // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
}
```

## Correlate without duplicating data

Use `external_id` to relate the process in your system. `subject.tenant_subject_id` identifies the person in your database. These identifiers serve different purposes and do not replace the `transaction_id` returned by the API.

`source` describes the originating system, integration and channel. `metadata` holds business context, limited to 8 KB; avoid sensitive or unnecessary data. Set expiration with `expires_in_minutes` within the contract’s bounds; omitting it uses the Tenant default.

## Handle the response

The documented response is `201`, including creation recovered through idempotency. Persist the identifier before directing the person to the journey. If the response is lost after sending the request, repeat the same creation with the same key and payload; do not automatically generate a new key.

When provided, `callback_url` sets the webhook for that transaction; otherwise, the Tenant configuration applies. Review [idempotency](/en/developers/kyc/idempotency) and [webhooks](/en/developers/kyc/webhooks-and-events) before moving this workflow into production.


---

# Hand the person over to the hosted journey

Source: https://contrato.cloud/en/developers/kyc/hosted-journey

## Use the URL returned by the API

After creating a transaction, use a hosted journey link returned in the response, such as `links.webview_url`. Do not invent a URL by concatenating `transaction_id`, an identity number or a customer identifier. Documentation URLs are synthetic and do not identify a real person.

The person completes verification in a compatible browser. Your application does not need to receive or store the API key to open this workflow. The key remains on the server that created the transaction.

## Treat the link as sensitive access

Share it only with the person and through channels allowed by your process. Do not put the complete URL in public analytics tools, uncontrolled logs or open support messages. Protect short links too: they remain a way to access the journey.

Required steps depend on the configuration that applies to the transaction. Do not replace a required step with a local screen, and do not mark the journey complete because the person closed the browser.

## Track results rather than guessing

Opening a link is not approval. Read the transaction through your backend and process authenticated events. An interruption can leave the flow pending; expiration requires a decision in your process without borrowing results from another transaction.

See [results and status](/en/developers/kyc/results-and-status) to decide how to proceed and [webhooks](/en/developers/kyc/webhooks-and-events) to receive updates independently of the person’s screen.


---

# Interpret KYC results, decisions and status

Source: https://contrato.cloud/en/developers/kyc/results-and-status

## Read the transaction as a whole

`status` describes journey progress. `decision` is `pending`, `approved` or `rejected`. When present, `decision_reason` adds the documented reason. Use the reference for the complete object and current enums; do not infer a decision from a single stage name.

| Scenario | Application handling |
| --- | --- |
| `created` and intermediate stages | Keep the process pending |
| `manual_review_pending` | Wait for review; do not release as approved |
| `approved` with `decision=approved` | Apply your rules for continuing the process |
| `rejected` with `decision=rejected` | Handle rejection according to your procedure |
| `expired` | Close or restart according to your policy |

Statuses such as `liveness_passed` or `document_passed` describe a completed step and do not replace the final decision. An interrupted connection is not evidence of rejection or approval either.

## Synchronize your business decision

Store the relation between your process and the transaction. When an event arrives, verify its origin, recognize duplicates and read current state before performing irreversible effects. An older event must not overwrite a newer decision merely because it arrives later.

KYC approval does not determine credit approval, the absence of payment fraud or a person’s authority to represent an organization. Your operation remains responsible for those assessments. See [redelivery and reconciliation](/en/developers/kyc/redelivery-and-reconciliation) and exercise these scenarios in the [simulator](/en/developers/kyc/simulator).


---

# Idempotency: retry creation without duplicating a transaction

Source: https://contrato.cloud/en/developers/kyc/idempotency

## One key for the same intent

Send `Idempotency-Key` when creating a transaction. The contract makes the header optional, but using it is recommended to tolerate a lost connection after the server receives your request. Generate and persist the key before the first POST; reuse it only for the same creation and payload.

```http
Idempotency-Key: onboarding-demo-0001
```

## A lost response does not mean a lost creation

Suppose your server sends a request and receives a timeout. The transaction may already exist. Retrying with a new key can create a second journey. Repeating the same POST with the same key and payload can recover the idempotent creation; its documented response remains `201`.

Use `external_id` for business correlation, without treating it as an automatic replacement for the header. Preserve the relationship between key, attempt and transaction in your system.

## Handle conflicts explicitly

Using the same key with a different payload returns `409` with code `idempotency_conflict`. Do not bypass this result by silently changing the key. Determine whether the customer changed the data or your application reconstructed the request inconsistently.

A genuinely new intent gets a new key. A technical retry preserves the previous intent. Do not assume an undocumented key-retention window.

Creation idempotency does not replace [webhook](/en/developers/kyc/webhooks-and-events) deduplication. Use `event_id` for events and make business side effects idempotent as well.


---

# Receive webhooks and track KYC events

Source: https://contrato.cloud/en/developers/kyc/webhooks-and-events

## Configure a receiver on your backend

Set `callback_url` during creation to choose the destination for that transaction. If omitted, the Tenant webhook configuration applies. Your receiver must accept the documented format and preserve incoming bytes before parsing JSON.

The payload includes `api_version`, `event_id`, `event_type`, `created_at` and `transaction`. Headers `X-Face-Auth-Event-Id`, `X-Face-Auth-Event-Type`, `X-Face-Auth-Timestamp` and `X-Face-Auth-Signature` identify and authenticate delivery. See the [complete reference](/en/developers/kyc/reference) for event enums.

## A delivery may repeat

Deduplicating by `transaction_id` would discard different events from the same journey. Use `event_id` with durable, concurrency-safe storage. Verify the signature before trusting an event and check consistency between the envelope and header event identifiers.

The receiver can persist a verified event in a durable queue and process it separately. Return success only after durably accepting the work; an acknowledgment must not conceal data loss.

## Do not depend on arrival order

A retry or delay can deliver an older event after a newer one. Read the transaction with GET to reconcile its state before releasing business side effects. Make those effects idempotent so a repeated attempt cannot release something twice.

Continue with [HMAC verification](/en/developers/kyc/verify-hmac-signature) and [redelivery and reconciliation](/en/developers/kyc/redelivery-and-reconciliation). A production integration also handles invalid signatures, temporary unavailability and out-of-order events.


---

# Verify webhook HMAC signatures

Source: https://contrato.cloud/en/developers/kyc/verify-hmac-signature

## Preserve bytes before parsing JSON

The signature covers exactly `timestamp + '.' + raw_body`, using HMAC SHA-256 and your environment’s webhook secret. Read the timestamp from `X-Face-Auth-Timestamp` and the signature from `X-Face-Auth-Signature`, in `sha256=<hex>` format.

Do not reconstruct the message using `JSON.stringify` or another serialization of the parsed object. Whitespace, line breaks or property ordering change the bytes. Configure your framework to expose the raw body before JSON parsing.

## Backend verification

The example takes a time tolerance defined by your policy; it is not a delivery-time promise. Synchronize the server clock and assess redelivery together with your replay policy.

Verify the webhook signature

### JavaScript / Node.js

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";
// Read X-Face-Auth-Timestamp / X-Face-Auth-Signature. rawBody is the exact received Buffer.
export function verifyWebhook(rawBody, timestamp, signature, secret, maxAgeSeconds, nowSeconds = Date.now() / 1000) {
  if (!Buffer.isBuffer(rawBody) || !secret || !Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0 || !Number.isFinite(nowSeconds)) return false;
  if (typeof timestamp !== "string" || !/^[0-9]{1,16}$/.test(timestamp) || typeof signature !== "string" || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
  const sentAt = Number(timestamp);
  if (!Number.isSafeInteger(sentAt) || Math.abs(nowSeconds - sentAt) > maxAgeSeconds) return false;
  const expected = createHmac("sha256", secret).update(Buffer.from(timestamp + ".", "utf8")).update(rawBody).digest();
  const received = Buffer.from(signature.slice(7), "hex");
  return expected.length === received.length && timingSafeEqual(expected, received);
}
// Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

### TypeScript / Node.js

```typescript
import { createHmac, timingSafeEqual } from "node:crypto";
// Read X-Face-Auth-Timestamp / X-Face-Auth-Signature. rawBody is the exact received Buffer.
export function verifyWebhook(rawBody: Buffer, timestamp: string, signature: string, secret: string, maxAgeSeconds: number, nowSeconds = Date.now() / 1000): boolean {
  if (!Buffer.isBuffer(rawBody) || !secret || !Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0 || !Number.isFinite(nowSeconds)) return false;
  if (typeof timestamp !== "string" || !/^[0-9]{1,16}$/.test(timestamp) || typeof signature !== "string" || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
  const sentAt = Number(timestamp);
  if (!Number.isSafeInteger(sentAt) || Math.abs(nowSeconds - sentAt) > maxAgeSeconds) return false;
  const expected = createHmac("sha256", secret).update(Buffer.from(timestamp + ".", "utf8")).update(rawBody).digest();
  const received = Buffer.from(signature.slice(7), "hex");
  return expected.length === received.length && timingSafeEqual(expected, received);
}
// Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

### Python

```python
from __future__ import annotations
import hashlib
import hmac
import math
import re
import time

def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str,
                   max_age_seconds: float, now_seconds: float | None = None) -> bool:
    now = time.time() if now_seconds is None else now_seconds
    if not isinstance(raw_body, bytes) or not secret or not math.isfinite(max_age_seconds) or max_age_seconds <= 0 or not math.isfinite(now):
        return False
    if not isinstance(timestamp, str) or not re.fullmatch(r"[0-9]{1,16}", timestamp):
        return False
    if not isinstance(signature, str) or not re.fullmatch(r"sha256=[a-f0-9]{64}", signature):
        return False
    sent_at = int(timestamp)
    if sent_at > 9007199254740991 or abs(now - sent_at) > max_age_seconds:
        return False
    expected = "sha256=" + hmac.new(secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

# Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

### PHP

```php
<?php
// rawBody is the unchanged byte string from php://input, before json_decode.
function verifyWebhook(string $rawBody, string $timestamp, string $signature, string $secret, float $maxAgeSeconds, ?float $nowSeconds = null): bool {
    $now = $nowSeconds ?? microtime(true);
    if ($secret === '' || !is_finite($maxAgeSeconds) || $maxAgeSeconds <= 0 || !is_finite($now)) return false;
    if (!preg_match('/^[0-9]{1,16}$/D', $timestamp) || !preg_match('/^sha256=[a-f0-9]{64}$/D', $signature)) return false;
    $sentAt = (float) $timestamp;
    if ($sentAt > 9007199254740991 || abs($now - $sentAt) > $maxAgeSeconds) return false;
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}
// Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

### C# / .NET

```csharp
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
// rawBody must be the unchanged request bytes, before JSON deserialization.
public static class Webhook {
  public static bool Verify(byte[] rawBody, string timestamp, string signature, string secret, double maxAgeSeconds, double? nowSeconds = null) {
    double now = nowSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
    if (rawBody is null || string.IsNullOrEmpty(secret) || !double.IsFinite(maxAgeSeconds) || maxAgeSeconds <= 0 || !double.IsFinite(now)) return false;
    if (timestamp is null || signature is null || !Regex.IsMatch(timestamp, @"\A[0-9]{1,16}\z") || !Regex.IsMatch(signature, @"\Asha256=[a-f0-9]{64}\z")) return false;
    if (!long.TryParse(timestamp, out long sentAt) || sentAt > 9007199254740991L || Math.Abs(now - sentAt) > maxAgeSeconds) return false;
    byte[] prefix = Encoding.UTF8.GetBytes(timestamp + ".");
    byte[] signed = new byte[prefix.Length + rawBody.Length];
    Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
    Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
    byte[] expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
    return CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(signature[7..]));
  }
}
// Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

### Java

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
// Java 17+. rawBody is the original request byte[], before JSON parsing.
class Webhook {
  static boolean verify(byte[] rawBody, String timestamp, String signature, String secret, double maxAgeSeconds, double nowSeconds) throws Exception {
    if (rawBody == null || secret == null || secret.isEmpty() || !Double.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0 || !Double.isFinite(nowSeconds)) return false;
    if (timestamp == null || signature == null || !timestamp.matches("[0-9]{1,16}") || !signature.matches("sha256=[a-f0-9]{64}")) return false;
    long sentAt = Long.parseLong(timestamp);
    if (sentAt > 9007199254740991L || Math.abs(nowSeconds - sentAt) > maxAgeSeconds) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    byte[] expected = mac.doFinal(rawBody);
    return MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature.substring(7)));
  }
}
// Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

### Go

```go
package main
import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "math"
  "regexp"
  "strconv"
)
// rawBody contains the exact request bytes, before json.Unmarshal. nowSeconds comes from your clock.
func verifyWebhook(rawBody []byte, timestamp, signature, secret string, maxAgeSeconds, nowSeconds float64) bool {
  if len(secret) == 0 || math.IsNaN(maxAgeSeconds) || math.IsInf(maxAgeSeconds, 0) || maxAgeSeconds <= 0 || math.IsNaN(nowSeconds) || math.IsInf(nowSeconds, 0) { return false }
  if !regexp.MustCompile("^[0-9]{1,16}$").MatchString(timestamp) || !regexp.MustCompile("^sha256=[a-f0-9]{64}$").MatchString(signature) { return false }
  sentAt, err := strconv.ParseInt(timestamp, 10, 64)
  if err != nil || sentAt > 9007199254740991 || math.Abs(nowSeconds-float64(sentAt)) > maxAgeSeconds { return false }
  mac := hmac.New(sha256.New, []byte(secret)); mac.Write([]byte(timestamp + ".")); mac.Write(rawBody)
  received, err := hex.DecodeString(signature[7:]); if err != nil { return false }
  return hmac.Equal(mac.Sum(nil), received)
}
// Only after signature verification: parse and validate the envelope, compare X-Face-Auth-Event-Id with event_id, and durably accept with a unique event_id (atomic insert/queue). A duplicate event_id is acknowledged without repeating effects. Do not deduplicate transaction_id. GET the transaction to reconcile current status, decision and decision_reason; delivery order is not authoritative. Acknowledge only after durable acceptance. Persistence, queueing, body limits and business effects belong to your application; this function only verifies HMAC. maxAgeSeconds is YOUR replay policy, not a service delivery promise. Never reserialize JSON before verification or log secrets/payloads.
```

## After verification

Only after verifying HMAC should you parse JSON, validate the envelope and process the event. Check the event identifier in the header against the body. Deduplicate by `event_id` using durable storage; cryptographic verification alone does not prevent repeating a business side effect.

These examples isolate verification and do not implement a complete receiver. Add body limits, exception handling, persistence, queueing, protected logging and [reconciliation](/en/developers/kyc/redelivery-and-reconciliation). Never log the secret, personal-data payloads or journey links in public tools.


---

# Event redelivery, duplicates and reconciliation

Source: https://contrato.cloud/en/developers/kyc/redelivery-and-reconciliation

## Delivery and processing are different problems

A delivery can fail, repeat or arrive late. The contract includes delivery states such as `retry_pending` and `failed`; do not assume a fixed retry interval or exactly-once delivery. Confirm operational settings for your integration.

1. Verify the signature and envelope before trusting an event.
2. Persist acceptance with a unique `event_id`, preventing races between receivers.
3. Make business continuation idempotent.
4. Read the transaction with GET to reconcile current state.

If your receiver acknowledges before persistence and then crashes, work can be lost. If it persists and the acknowledgment is lost, delivery can repeat. Your design must handle both cases.

## Arrival order is not authoritative state

A delayed `created` event must not overwrite a final state already known to your application. The transaction identifier locates the journey; `event_id` identifies each event. Call `GET /v1/face-auth/transactions/{transaction_id}` using the correct Tenant and environment key.

## Prepare operational recovery

Record the identifier, event type, processing result and sanitized failure. Do not copy personal-data payloads or credentials into open logs. Define how your team finds pending work, performs a fresh read and avoids repeating releases, charges or other effects.

Test duplicate events, two events out of order, a failure before persistence and one afterward. The [simulator](/en/developers/kyc/simulator) explains scenarios; your queue and storage infrastructure still require their own tests.


---

# Authorized documents, assets and evidence downloads

Source: https://contrato.cloud/en/developers/kyc/documents-and-evidence

## Authorized files in the integration

The Tenant API can list authorized assets at `/assets` and request permitted content at `/assets/{asset_id}/content`, after `/v1/face-auth/transactions/{transaction_id}`. These server-to-server calls require an API key, the `document_download` scope and an authorized IP.

See [authentication](/en/developers/kyc/authentication) and response types in the [reference](/en/developers/kyc/reference). Use identifiers returned by the listing; content authorization is checked separately and listing an asset does not release internal evidence.

## Evidence ZIP and PDF are restricted

The `/documents/download` and `/evidence.pdf` routes do not provide downloads to Tenant integrations. Even with a valid key, scope and authorized IP, they return `403 evidence_access_restricted`. Access to this evidence is reserved for internal Admin. The reference includes the routes to explain the restriction; the Postman requests exclude them.

Do not retry or broaden the key to bypass this denial. Knowing a link or identifier does not grant access. Authentication, scope or IP errors may be returned before the evidence restriction.

## Protect permitted content

The key must belong to the same Tenant and environment as the transaction. Transfer only authorized content necessary for the approved purpose. Control who can open, export and share files in your application; avoid public caches and permanent unauthenticated URLs. Copies containing personal data also require safeguards and an appropriate retention policy.

For troubleshooting, send support identifiers and sanitized errors, never credentials or documents through open channels. Available records do not guarantee universal compliance or replace legal assessment of an agreement.


---

# KYC integration errors and troubleshooting

Source: https://contrato.cloud/en/developers/kyc/errors-and-troubleshooting

## Start with code, operation and environment

Documented error responses use a `detail` object containing `code` and `message`. Check the HTTP status too. Do not treat an error JSON response as a successfully created transaction.

| Code | Recommended check |
| --- | --- |
| `validation_error` | Required fields, types, country, locale and contract bounds |
| `invalid_api_key` | Credential, revocation and environment |
| `tenant_environment_disabled` | Tenant and environment enablement |
| `transaction_not_found` | Correct identifier, Tenant and environment |
| `idempotency_conflict` | Same key used with a different payload |
| `tenant_ip_not_authorized` | Authorized egress IP for the operation |
| `api_key_scope_not_allowed` | Key permissions for the resource |

## Separate connection failures from contract denials

A lost creation response leaves an uncertain result. Use the same idempotency key and payload according to the [idempotency guide](/en/developers/kyc/idempotency). Deterministic credential, scope or payload errors need correction; unlimited retries only conceal the cause.

For temporary unavailability, configure timeouts and a bounded retry policy in your client. Do not assume an availability promise or a universal attempt limit.

## Share useful diagnostics

Record the time, environment, method, path without secrets, HTTP status and sanitized error code. Include the transaction identifier in a support request when available. Remove API keys, webhook secrets, personal data and journey links. Compare your request with the [reference](/en/developers/kyc/reference) and [examples](/en/developers/kyc/examples-and-downloads) before changing business behavior.


---

# Contract bounds and integration best practices

Source: https://contrato.cloud/en/developers/kyc/limits-and-best-practices

## Validate before sending

Use bounds defined by OpenAPI rather than creating a second contract in your application. During creation, `external_id` accepts up to 128 characters; `Idempotency-Key`, up to 180; `metadata`, up to 8 KB. When provided, `expires_in_minutes` ranges from 5 to 129600 minutes. Omitting an optional field is different from sending an arbitrary value.

Read countries, locales, statuses and codes from the current reference. For example, the website language `en` does not authorize replacing an API locale enum with that same value.

## Control client behavior

Configure connection and response timeouts. Limit concurrency, monitor your queue and use progressive delay for transient failures. Correct authentication, permission or payload errors before retrying. Do not continuously query every transaction without an interval.

This guide does not publish a universal request quota or SLA. Confirm the operational and commercial limits that apply to your integration with the responsible team.

## Minimize what you need to protect

Send only data necessary for your process. Do not use `metadata` as storage for documents, secrets or extensive personal-data payloads. Avoid logging complete request bodies. Protect journey links and downloaded files as sensitive access.

Combine [webhooks](/en/developers/kyc/webhooks-and-events) with [reconciliation](/en/developers/kyc/redelivery-and-reconciliation) instead of relying only on frequent polling. Review the [go-live checklist](/en/developers/kyc/go-live) to connect these practices to your operation.


---

# Go-live checklist for your KYC integration

Source: https://contrato.cloud/en/developers/kyc/go-live

## Confirm environment and access

- Official API address confirmed for your enabled environment.
- Key stored only on your backend with necessary permissions.
- Credentials and data separated between testing and production.
- Authorized IPs and download scope when accessing files.

## Cover the complete lifecycle

Create a transaction, persist its correlation, open the returned journey and read its result. Cover rejection, expiration and manual review too; testing only approval leaves important decisions unhandled. An intermediate step must not release the process as approved.

Simulate a lost POST response and confirm retries use the same idempotency key. Send duplicate and out-of-order events to your consumer and verify GET reconciliation without repeating business side effects.

## Check the event receiver

- Raw body preserved before JSON parsing.
- HMAC and time policy verified before processing.
- Durable acceptance, deduplication by `event_id` and failure recovery.
- Sanitized logs with no secrets in URLs or public tools.

## Prepare operations

Assign responsibility for pending cases, delivery failures and expired transactions. Document how to pause business continuation during unavailability, reconcile afterward and contact support without unnecessary personal data.

Confirm purpose, access and retention requirements with the responsible teams. The simulator checks understanding of the contract; it does not certify your infrastructure or perform real identity verification. Production launch depends on the enablement and operational acceptance agreed for your integration.


---

# KYC public contract history and evolution

Source: https://contrato.cloud/en/developers/kyc/changelog

## October 16, 2026 — developer portal

This documentation edition organizes the KYC B2B contract into guides, reference, downloads and local simulation. The source specification declares `info.version=1.0.0`. That version identifies the documented contract; publishing guides does not announce a behavioral API change.

The public reference documents four supported Tenant operations and two legacy evidence routes that return `403 evidence_access_restricted`, reserved for internal Admin. Postman includes only the four supported operations. We do not publish Admin operations, internal sessions or infrastructure controls. The exporter combines the curated source with the restriction verified in current code, without importing the application.

## How to assess an update

Before updating your consumer, compare required fields, enums, error responses and authorization. Preserve handling for documented values, and do not confuse an article’s revision date with an operational environment’s version.

Repeat idempotent creation, reads, results and webhook tests. Verify the bytes used for HMAC and event deduplication. If your application downloads files, confirm scopes, IPs and file types too.

## Keep an integration record

Record the specification version used and when your client was reviewed. An editorial guide change does not automatically require migration. Incompatible changes need specific contract-evolution guidance.

Download the [OpenAPI specification](/downloads/kyc/openapi.json), explore [examples](/en/developers/kyc/examples-and-downloads) and use the [reference](/en/developers/kyc/reference) to confirm documented behavior before modifying code.


---

# KYC API examples and integration downloads

Source: https://contrato.cloud/en/developers/kyc/examples-and-downloads

## Public contract files

- [OpenAPI JSON specification](/downloads/kyc/openapi.json)
- [Postman collection](/downloads/kyc/postman-collection.json)
- [Postman environment without credentials](/downloads/kyc/postman-environment.json)

Import the files and set the official URL and enabled key only in your private environment. Never publish a workspace containing secrets. Examples below use fictitious data; timeout values demonstrate client choices, not an SLA.

## Backend HTTP examples

Create a KYC transaction

### cURL

```bash
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
set -eu
: "${KYC_API_BASE_URL:?Set authorized base}" "${KYC_API_KEY:?Set API key}"
: "${KYC_IDEMPOTENCY_KEY:?Persist the key for this creation intent}"
curl --fail-with-body --max-time 15 --request POST \
  "${KYC_API_BASE_URL%/}/v1/face-auth/transactions" \
  --header "X-API-Key: ${KYC_API_KEY}" \
  --header "Idempotency-Key: ${KYC_IDEMPOTENCY_KEY}" \
  --header 'Content-Type: application/json' \
  --data-binary '{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}'
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### JavaScript / Node.js

```javascript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name) {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions", {
  method: "POST",
  headers: { "X-API-Key": required("KYC_API_KEY"), "Content-Type": "application/json", "Idempotency-Key": required("KYC_IDEMPOTENCY_KEY") },
  body: "{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}",
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 201) throw new Error("KYC HTTP " + response.status);
const transaction = await response.json();
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### TypeScript / Node.js

```typescript
import process from "node:process";
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error("Missing configuration: " + name);
  return value;
}
const base = required("KYC_API_BASE_URL").replace(/\/$/, "");
const response = await fetch(base + "/v1/face-auth/transactions", {
  method: "POST",
  headers: { "X-API-Key": required("KYC_API_KEY"), "Content-Type": "application/json", "Idempotency-Key": required("KYC_IDEMPOTENCY_KEY") },
  body: "{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}",
  signal: AbortSignal.timeout(15_000),
});
if (response.status !== 201) throw new Error("KYC HTTP " + response.status);
const transaction: unknown = await response.json();
// Validate the response against the curated OpenAPI before using it.
console.log("HTTP " + response.status);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Python

```python
import json
import os
from urllib.parse import quote
from urllib.request import Request, urlopen
# Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
base = os.environ["KYC_API_BASE_URL"].rstrip("/")
request = Request(base + "/v1/face-auth/transactions",
    method="POST",
    headers={"X-API-Key": os.environ["KYC_API_KEY"], "Content-Type": "application/json",
             "Idempotency-Key": os.environ["KYC_IDEMPOTENCY_KEY"]},
    data="{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}".encode("utf-8"),
)
with urlopen(request, timeout=15) as response:
    if response.status != 201:
        raise RuntimeError("KYC HTTP " + str(response.status))
    transaction = json.load(response)
    print("HTTP " + str(response.status))
# Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### PHP

```php
<?php
// PHP 8+, standard HTTP streams. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
function required(string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '') throw new RuntimeException("Missing configuration: " . $name);
    return $value;
}
$url = rtrim(required('KYC_API_BASE_URL'), '/') . '/v1/face-auth/transactions';
$headers = ['X-API-Key: ' . required('KYC_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . required('KYC_IDEMPOTENCY_KEY')];
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers),
    'timeout' => 15, 'ignore_errors' => true, 'content' => '{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}']]);
$body = file_get_contents($url, false, $context);
if ($body === false) throw new RuntimeException('KYC transport failure');
if (!preg_match('/^HTTP\/\S+ ([0-9]{3})/', $http_response_header[0] ?? '', $matches)) throw new RuntimeException('Invalid HTTP response');
$status = (int) $matches[1];
if ($status !== 201) throw new RuntimeException('KYC HTTP ' . $status);
$transaction = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
echo 'HTTP ' . $status . PHP_EOL;
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### C# / .NET

```csharp
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
// .NET 8+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : throw new Exception("Missing configuration: " + name);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
using var request = new HttpRequestMessage(HttpMethod.Post, Required("KYC_API_BASE_URL").TrimEnd('/') + "/v1/face-auth/transactions");
request.Headers.Add("X-API-Key", Required("KYC_API_KEY"));
request.Headers.Add("Idempotency-Key", Required("KYC_IDEMPOTENCY_KEY"));
request.Content = new StringContent("{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
if ((int)response.StatusCode != 201) throw new Exception("KYC HTTP " + (int)response.StatusCode);
using var transaction = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine("HTTP " + (int)response.StatusCode);
// Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
```

### Java

```java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
// Java 17+. Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
class Example {
  static String required(String name) {
    String value = System.getenv(name);
    if (value == null || value.isEmpty()) throw new IllegalStateException("Missing configuration: " + name);
    return value;
  }
  public static void main(String[] args) throws Exception {
    String base = required("KYC_API_BASE_URL").replaceAll("/+$", "");
    String url = base + "/v1/face-auth/transactions";
    var builder = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15))
        .header("X-API-Key", required("KYC_API_KEY"));
    builder.header("Idempotency-Key", required("KYC_IDEMPOTENCY_KEY"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"country_code\":\"BR\",\"external_id\":\"docs-example-0001\",\"locale\":\"pt-BR\",\"subject\":{\"identity\":{\"country\":\"BR\",\"number\":\"00000000000\",\"type\":\"CPF\"}}}", StandardCharsets.UTF_8));
    var response = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
    if (response.statusCode() != 201) throw new IllegalStateException("KYC HTTP " + response.statusCode());
    System.out.println("HTTP " + response.statusCode());
    // Parse response.body() with your application's JSON parser and validate against OpenAPI.
    // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
  }
}
```

### Go

```go
package main
import (
  "encoding/json"
  "fmt"
  "net/http"
  "net/url"
  "os"
  "strings"
  "time"
)
// Server-side only. Configure KYC_API_BASE_URL with your authorized base (https://api.example.invalid is illustrative), and KYC_API_KEY privately. Never expose credentials in browser code.
func required(name string) string {
  value := os.Getenv(name); if value == "" { panic("Missing configuration: " + name) }; return value
}
func main() {
  _ = url.PathEscape // Used by the GET variant.
  endpoint := strings.TrimRight(required("KYC_API_BASE_URL"), "/") + "/v1/face-auth/transactions"
  request, err := http.NewRequest("POST", endpoint, strings.NewReader(`{"country_code":"BR","external_id":"docs-example-0001","locale":"pt-BR","subject":{"identity":{"country":"BR","number":"00000000000","type":"CPF"}}}`)); if err != nil { panic(err) }
  request.Header.Set("X-API-Key", required("KYC_API_KEY"))
  request.Header.Set("Content-Type", "application/json")
  request.Header.Set("Idempotency-Key", required("KYC_IDEMPOTENCY_KEY"))
  response, err := (&http.Client{Timeout: 15*time.Second}).Do(request); if err != nil { panic(err) }; defer response.Body.Close()
  if response.StatusCode != 201 { panic(fmt.Sprintf("KYC HTTP %d", response.StatusCode)) }
  var transaction map[string]any
  if err := json.NewDecoder(response.Body).Decode(&transaction); err != nil { panic(err) }
  fmt.Printf("HTTP %d\n", response.StatusCode)
  // Persist transaction_id and business correlation; use the returned links.webview_url. Read status, decision and decision_reason together. Reuse the SAME idempotency key AND original payload only for the same creation intent; never silently bypass a 409 conflict.
}
```

These snippets demonstrate basic creation; they are not official SDKs or complete consumers. Add configuration validation, persistence, error handling and idempotent retry behavior appropriate to your application. A new creation intent needs a new key; a technical retry preserves the previous key and payload.

For cURL, see the [quickstart](/en/developers/kyc/quickstart). To build an event receiver, follow [HMAC verification](/en/developers/kyc/verify-hmac-signature) and [reconciliation](/en/developers/kyc/redelivery-and-reconciliation). The [simulator](/en/developers/kyc/simulator) demonstrates fixed scenarios without accessing the real API.


---

# Explore the local KYC simulator

Source: https://contrato.cloud/en/developers/kyc/simulator-guide

## Understand the contract before integrating

The [local simulator](/en/developers/kyc/simulator) explores fixed creation and result scenarios. It runs in the browser with synthetic data. It does not request an identity number, document, selfie, API key or webhook secret, and it does not perform identity verification.

Use it to align product and engineering on what your system should do when a journey stays pending, is approved, is rejected, expires or requires manual review. Observe the difference between `status` and `decision` instead of releasing a process based on an intermediate stage.

## An exploration path

1. Start with creation and identify the synthetic `transaction_id`.
2. Select available results and compare their states.
3. Map every scenario to a screen or action in your application.
4. Read why duplicates and delayed events require reconciliation.

Record which decisions need a person and which can continue automatically under your policy. The goal is to turn a technical payload into an understandable operational rule.

## What your integration still needs to test

The simulator does not certify credentials, networking, message delivery, biometrics or persistence. Those behaviors require validation in an enabled environment and your application’s infrastructure. Do not submit real data to extend the exercise.

Continue with the [quickstart](/en/developers/kyc/quickstart), validate your [webhook](/en/developers/kyc/webhooks-and-events) consumer and complete the [go-live checklist](/en/developers/kyc/go-live).
