# Telvera API

# Premiers pas

## Vue d’ensemble

Telvera confirme un numéro de téléphone **par un appel entrant** au lieu d’un code SMS. Vous affichez un numéro à
l’utilisateur, il l’appelle depuis son téléphone, nous raccrochons sans décrocher (gratuit pour lui) et indiquons à
votre serveur que le numéro est vérifié.

L’API, ce sont deux requêtes et un webhook :

1. `POST /v1/verify` — vous envoyez le numéro de l’utilisateur et recevez `call_to`, le numéro qu’il doit appeler.
2. L’utilisateur appelle `call_to` depuis son téléphone.
3. Nous envoyons le webhook `verification.verified` (ou vous consultez le statut via `GET /v1/verify/{id}`).

Requêtes et réponses en JSON, heures en ISO 8601 (UTC), numéros dans
les réponses au format E.164.

## Démarrage rapide

**1. Obtenez une clé** dans le tableau de bord : « Paramètres → API ». Envoyez-la dans l’en-tête
`Authorization: Bearer sk_live_…`. Conservez-la uniquement sur votre serveur.

**2. Demandez un numéro à appeler :**

```bash
curl https://api.telvera.io/v1/verify \
  -H "Authorization: Bearer $TELVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-7f3c2a" \
  -d '{"phone": "+375290000001"}'
```
```php
<?php
$ch = curl_init('https://api.telvera.io/v1/verify');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('TELVERA_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: signup-' . $sessionId,
    ],
    CURLOPT_POSTFIELDS     => json_encode(['phone' => '+375290000001']),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['code'] ?? 'http_' . $status);
}
echo $body['call_to']; // à afficher à l’utilisateur
```
```js
const response = await fetch('https://api.telvera.io/v1/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TELVERA_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `signup-${sessionId}`,
  },
  body: JSON.stringify({ phone: '+375290000001' }),
});
const body = await response.json().catch(() => ({})); // un proxy peut renvoyer du HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);

console.log(body.call_to); // à afficher à l’utilisateur
```
```python
import os
import requests

response = requests.post(
    "https://api.telvera.io/v1/verify",
    headers={
        "Authorization": f"Bearer {os.environ['TELVERA_API_KEY']}",
        "Idempotency-Key": f"signup-{session_id}",
    },
    json={"phone": "+375290000001"},
    timeout=10,
)
response.raise_for_status()  # 4xx/5xx lève une exception ; le code est dans response.json()["error"]["code"]
verification = response.json()

print(verification["call_to"])  # à afficher à l’utilisateur
```
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("TELVERA_API_KEY"));

var request = new HttpRequestMessage(HttpMethod.Post, "https://api.telvera.io/v1/verify")
{
    Content = JsonContent.Create(new { phone = "+375290000001" }),
};
request.Headers.Add("Idempotency-Key", $"signup-{sessionId}");
var response = await http.SendAsync(request);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
    throw new Exception(body.GetProperty("error").GetProperty("code").GetString());

Console.WriteLine(body.GetProperty("call_to").GetString()); // à afficher à l’utilisateur
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

var request = HttpRequest.newBuilder(URI.create("https://api.telvera.io/v1/verify"))
    .header("Authorization", "Bearer " + System.getenv("TELVERA_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "signup-" + sessionId)
    .POST(HttpRequest.BodyPublishers.ofString("{\"phone\": \"+375290000001\"}"))
    .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());

String json = response.body(); // le corps est du JSON avec call_to ; analysez-le avec Jackson ou Gson
```
```go
req, _ := http.NewRequest("POST", "https://api.telvera.io/v1/verify",
	strings.NewReader(`{"phone": "+375290000001"}`))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TELVERA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "signup-"+sessionID)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var v struct {
	ID     string `json:"id"`
	CallTo string `json:"call_to"`
	Error  struct{ Code string `json:"code"` } `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&v)
if resp.StatusCode >= 400 {
	log.Fatalf("telvera: %d %s", resp.StatusCode, v.Error.Code)
}
fmt.Println(v.CallTo) // à afficher à l’utilisateur
```
```ruby
require "net/http"
require "json"

response = Net::HTTP.post(URI("https://api.telvera.io/v1/verify"), { phone: "+375290000001" }.to_json,
  "Authorization" => "Bearer #{ENV.fetch("TELVERA_API_KEY")}", "Content-Type" => "application/json",
  "Idempotency-Key" => "signup-#{session_id}")
body = JSON.parse(response.body) rescue {}
raise "telvera: #{body.dig("error", "code") || response.code}" unless response.is_a?(Net::HTTPSuccess)

puts body["call_to"] # à afficher à l’utilisateur
```

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "pending",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": null,
  "metadata": null,
  "created_at": "2026-10-08T15:38:13Z"
}
```

**3. Affichez `call_to` à l’utilisateur** — bouton `tel:` sur mobile, QR code sur ordinateur — avec un compte à rebours jusqu’à `expires_at`.

**4. Récupérez le résultat.** Indiquez l’URL du webhook sur la clé dans le tableau de bord — après l’appel, elle
reçoit `verification.verified` ; [vérifiez sa signature](#signature). Sans webhook, interrogez `GET /v1/verify/{id}`
toutes les 2–3 secondes tant que le statut est `pending`.

# Référence

## L’objet vérification

Les deux méthodes renvoient une vérification, et le webhook la contient dans `data`.

| Champ | Type | Description |
|---|---|---|
| `id` | string | Identifiant : `vrf_` suivi de 32 caractères hex |
| `status` | string | `pending` — en attente de l’appel, `verified` — numéro confirmé, `expired` — pas d’appel |
| `phone` | string | Numéro de l’utilisateur, E.164 |
| `call_to` | string | Numéro que l’utilisateur doit appeler, E.164 |
| `expires_at` | string | Jusqu’à quand nous attendons l’appel : une minute après la création |
| `verified_at` | string \| null | Moment où l’appel a été pris en compte |
| `metadata` | object \| null | Vos données de la requête, inchangées : même objet, mêmes types |
| `created_at` | string | Date de création |

`verified` et `expired` sont définitifs. Un appel passé à temps peut nous parvenir avec du retard : pendant encore 35 secondes après `expires_at`, la vérification reste `pending` et peut encore être confirmée — sauf si vous en créez une nouvelle pour ce numéro entre-temps.

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "pending",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": null,
  "metadata": { "user_id": "42" },
  "created_at": "2026-10-08T15:38:13Z"
}
```

## Créer une vérification

`POST /v1/verify`

Renvoie le numéro que l’utilisateur doit appeler — il dispose d’une minute. Une vérification n’est déduite du pack que lorsque le statut passe à `verified`.

### Paramètres du corps

| Paramètre | Type | Obligatoire | Description |
|---|---|---|---|
| `phone` | string | oui | Numéro de l’utilisateur au format international ; espaces, parenthèses et tirets acceptés. Absent ou non reconnu — `invalid_phone` |
| `metadata` | object | non | Objet plat de 20 champs maximum. Clé — chaîne jusqu’à 64 caractères ; valeur — chaîne jusqu’à 500 caractères, nombre, `true`/`false` ou `null`. Renvoyé dans les réponses et le webhook |
| `Idempotency-Key` | en-tête | non | Clé de répétition — une par session/tentative de l’utilisateur, p. ex. `signup-<id de session>`. Une répétition avec la même clé renvoie la même vérification. 1 à 255 caractères ASCII imprimables |

### Réponse

`201 Created` et une nouvelle [vérification](#object) au statut `pending`. Une répétition avec le même `Idempotency-Key` — `200 OK`, en-tête `Idempotent-Replayed: true` et **la même** vérification.

### Erreurs

| HTTP | `code` | Quand |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Clé absente, erronée ou révoquée |
| 402 | [`insufficient_checks`](#error-insufficient-checks) | Plus de vérifications dans le pack |
| 403 | [`account_disabled`](#error-account-disabled) | Compte bloqué |
| 403 | [`test_phone_only`](#error-test-phone-only) | Clé de test avec le numéro de quelqu’un d’autre |
| 422 | [`invalid_phone`](#error-invalid-phone) | `phone` absent ou non reconnu |
| 422 | [`phone_not_supported`](#error-phone-not-supported) | Les numéros de ce pays ne sont pas encore pris en charge ; réessayer n’y changera rien |
| 422 | [`idempotency_key_reused`](#error-idempotency-key-reused) | Le même `Idempotency-Key` a déjà servi avec un autre numéro |
| 422 | [`invalid_request`](#error-invalid-request) | Le corps n’est pas du JSON, `metadata` n’est pas un objet plat ou dépasse 20 champs |
| 429 | [`rate_limited`](#error-rate-limited) | Plus de 120 requêtes par minute par clé, ou plus de 5 nouvelles vérifications du même numéro en 10 minutes |
| 503 | [`unavailable`](#error-unavailable) | Aucun numéro libre pour le moment ; réessayez après `Retry-After` secondes |

**Répétitions.** Envoyez un `Idempotency-Key` lié à la session de l’utilisateur. Une répétition avec la même clé
pendant 24 heures (double clic, coupure réseau) renvoie la même vérification — rien n’est facturé et la limite n’est
pas consommée. Sans clé, chaque requête crée une nouvelle vérification : nous ne détectons pas les répétitions par
numéro, sinon une autre session pourrait recevoir votre vérification.

**Autre numéro.** Si l’utilisateur n’arrive pas à appeler, recréez la vérification avec une nouvelle clé. Vous
obtenez un autre `call_to`, et la précédente passe immédiatement à `expired` (avec webhook) ; les appels vers son
numéro ne comptent plus.

```bash
curl https://api.telvera.io/v1/verify \
  -H "Authorization: Bearer $TELVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-7f3c2a" \
  -d '{"phone": "+375290000001", "metadata": {"user_id": "42"}}'
```
```php
<?php
$ch = curl_init('https://api.telvera.io/v1/verify');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('TELVERA_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: signup-' . $sessionId,
    ],
    CURLOPT_POSTFIELDS     => json_encode(['phone' => '+375290000001', 'metadata' => ['user_id' => '42']]),
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
    throw new RuntimeException($body['error']['code']);
}
```
```js
const response = await fetch('https://api.telvera.io/v1/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TELVERA_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `signup-${sessionId}`,
  },
  body: JSON.stringify({ phone: '+375290000001', metadata: { user_id: '42' } }),
});
const body = await response.json().catch(() => ({})); // un proxy peut renvoyer du HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);
```
```python
import os
import requests

response = requests.post(
    "https://api.telvera.io/v1/verify",
    headers={
        "Authorization": f"Bearer {os.environ['TELVERA_API_KEY']}",
        "Idempotency-Key": f"signup-{session_id}",
    },
    json={"phone": "+375290000001", "metadata": {"user_id": "42"}},
    timeout=10,
)
response.raise_for_status()
verification = response.json()
```
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("TELVERA_API_KEY"));

var request = new HttpRequestMessage(HttpMethod.Post, "https://api.telvera.io/v1/verify")
{
    Content = JsonContent.Create(new { phone = "+375290000001", metadata = new { user_id = "42" } }),
};
request.Headers.Add("Idempotency-Key", $"signup-{sessionId}");
var response = await http.SendAsync(request);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
    throw new Exception(body.GetProperty("error").GetProperty("code").GetString());
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

var request = HttpRequest.newBuilder(URI.create("https://api.telvera.io/v1/verify"))
    .header("Authorization", "Bearer " + System.getenv("TELVERA_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "signup-" + sessionId)
    .POST(HttpRequest.BodyPublishers.ofString("{\"phone\": \"+375290000001\", \"metadata\": {\"user_id\": \"42\"}}"))
    .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());

String json = response.body(); // le corps est du JSON avec call_to ; analysez-le avec Jackson ou Gson
```
```go
req, _ := http.NewRequest("POST", "https://api.telvera.io/v1/verify",
	strings.NewReader(`{"phone": "+375290000001", "metadata": {"user_id": "42"}}`))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TELVERA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "signup-"+sessionID)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var v struct {
	ID     string `json:"id"`
	CallTo string `json:"call_to"`
	Error  struct{ Code string `json:"code"` } `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&v)
if resp.StatusCode >= 400 {
	log.Fatalf("telvera: %d %s", resp.StatusCode, v.Error.Code)
}
```
```ruby
require "net/http"
require "json"

response = Net::HTTP.post(URI("https://api.telvera.io/v1/verify"), { phone: "+375290000001", metadata: { user_id: "42" } }.to_json,
  "Authorization" => "Bearer #{ENV.fetch("TELVERA_API_KEY")}", "Content-Type" => "application/json",
  "Idempotency-Key" => "signup-#{session_id}")
body = JSON.parse(response.body) rescue {}
raise "telvera: #{body.dig("error", "code") || response.code}" unless response.is_a?(Net::HTTPSuccess)
```

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "pending",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": null,
  "metadata": { "user_id": "42" },
  "created_at": "2026-10-08T15:38:13Z"
}
```

## Obtenir le statut

`GET /v1/verify/{id}`

Renvoie la vérification avec son statut actuel. Interrogez au plus une fois toutes les 2 secondes tant que le statut
est `pending` ; pour un gros volume, utilisez le [webhook](#webhooks).

### Paramètres de chemin

| Paramètre | Type | Obligatoire | Description |
|---|---|---|---|
| `id` | string | oui | L’ID de vérification de la réponse de `POST /v1/verify` |

### Réponse

`200 OK` et un [objet vérification](#object).

### Erreurs

| HTTP | `code` | Quand |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Clé absente, erronée ou révoquée |
| 403 | [`account_disabled`](#error-account-disabled) | Compte bloqué |
| 404 | [`not_found`](#error-not-found) | Cette vérification n’existe pas ou appartient à un autre compte |
| 429 | [`rate_limited`](#error-rate-limited) | Plus de 3000 requêtes de statut par minute par clé |

```bash
curl https://api.telvera.io/v1/verify/vrf_0d9a3d2b5ca2439b9af861470d5986bc \
  -H "Authorization: Bearer $TELVERA_API_KEY"
```
```php
<?php
$ch = curl_init('https://api.telvera.io/v1/verify/' . $verificationId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('TELVERA_API_KEY')],
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
    throw new RuntimeException($body['error']['code']);
}
```
```js
const response = await fetch(`https://api.telvera.io/v1/verify/${verificationId}`, {
  headers: { Authorization: `Bearer ${process.env.TELVERA_API_KEY}` },
});
const body = await response.json().catch(() => ({})); // un proxy peut renvoyer du HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);
```
```python
import os
import requests

response = requests.get(
    f"https://api.telvera.io/v1/verify/{verification_id}",
    headers={"Authorization": f"Bearer {os.environ['TELVERA_API_KEY']}"},
    timeout=10,
)
response.raise_for_status()
verification = response.json()
```
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("TELVERA_API_KEY"));

var response = await http.GetAsync($"https://api.telvera.io/v1/verify/{verificationId}");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
    throw new Exception(body.GetProperty("error").GetProperty("code").GetString());

var status = body.GetProperty("status").GetString();
```
```java
var request = HttpRequest.newBuilder(URI.create("https://api.telvera.io/v1/verify/" + verificationId))
    .header("Authorization", "Bearer " + System.getenv("TELVERA_API_KEY"))
    .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
```
```go
req, _ := http.NewRequest("GET", "https://api.telvera.io/v1/verify/"+verificationID, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TELVERA_API_KEY"))

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var v struct {
	Status string `json:"status"`
	Error  struct{ Code string `json:"code"` } `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&v)
if resp.StatusCode >= 400 {
	log.Fatalf("telvera: %d %s", resp.StatusCode, v.Error.Code)
}
```
```ruby
uri = URI("https://api.telvera.io/v1/verify/#{verification_id}")
request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV.fetch("TELVERA_API_KEY")}")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
body = JSON.parse(response.body) rescue {}
raise "telvera: #{body.dig("error", "code") || response.code}" unless response.is_a?(Net::HTTPSuccess)
```

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "verified",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": "2026-10-08T15:38:19Z",
  "metadata": { "user_id": "42" },
  "created_at": "2026-10-08T15:38:13Z"
}
```

## Erreurs

Une erreur a toujours la même forme : `{"error": {"code", "message"}}`. Traitez-la selon `code` — il ne change jamais ; `message` est une explication pour le développeur.

| HTTP | `code` | Que faire |
|---|---|---|
| 401 | `unauthorized` | Vérifiez la clé dans « Paramètres → API » |
| 402 | `insufficient_checks` | Achetez un pack dans le tableau de bord |
| 403 | `account_disabled` | Compte bloqué — contactez le support ; inutile de changer la clé |
| 403 | `test_phone_only` | Utilisez une clé réelle ou le numéro de votre compte |
| 404 | `not_found` | Vérifiez l’`id` |
| 405 | `invalid_request` | Méthode non prise en charge à cette URL (par ex. `DELETE /v1/verify/vrf_…`). Une URL inconnue renvoie `404` |
| 422 | `invalid_phone` | Envoyez le numéro au format international |
| 422 | `phone_not_supported` | Les numéros de ce pays ne sont pas encore pris en charge. Ne réessayez pas — proposez un autre moyen |
| 422 | `idempotency_key_reused` | Utilisez un nouvel `Idempotency-Key` pour chaque tentative de l’utilisateur |
| 422 | `invalid_request` | Corrigez le corps de la requête, détails dans `message` |
| 429 | `rate_limited` | Réessayez après `Retry-After` secondes |
| 500 | `internal_error` | Réessayez ; si l’erreur persiste, contactez le support avec le `X-Request-Id` |
| 503 | `unavailable` | Réessayez après `Retry-After` secondes |

**Limites** par clé, indépendantes : `POST /v1/verify` — 120 par minute et au plus 5 nouvelles vérifications
d’un numéro par 10 minutes (une répétition avec le même `Idempotency-Key` ne compte pas) ; `GET /v1/verify/{id}` — 3000
par minute.

**Compatibilité.** Dans `v1`, nous pouvons ajouter des champs aux réponses, des types d’événements et des codes
d’erreur. Ne rejetez pas une réponse à cause d’un champ inconnu et traitez les événements et codes inconnus comme le
cas général.

```json
{
  "error": {
    "code": "insufficient_checks",
    "message": "No verifications left. Buy a package in the dashboard."
  }
}
```


# Webhooks

## Webhooks

Indiquez l’URL sur la clé dans le tableau de bord (« Paramètres → API → Modifier »). Quand une vérification se
termine, nous y envoyons un `POST` avec un événement — un par vérification :

| Événement | Quand |
|---|---|
| `verification.verified` | Le numéro est confirmé |
| `verification.expired` | Aucun appel. Arrive 35 à 95 secondes après `expires_at`, ou immédiatement si une nouvelle vérification est créée pour le numéro |

```json
{
  "id": "evt_5b1f0c7e9a8d4c2f8e6a3b1d0c9f7e2a",
  "type": "verification.verified",
  "livemode": true,
  "created_at": "2026-10-08T15:38:19Z",
  "data": {
    "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
    "status": "verified",
    "phone": "+375290000001",
    "call_to": "+375170000002",
    "expires_at": "2026-10-08T15:39:13Z",
    "verified_at": "2026-10-08T15:38:19Z",
    "metadata": null,
    "created_at": "2026-10-08T15:38:13Z"
  }
}
```

- `data` est la [vérification](#object) au moment de l’événement ; `livemode: false` signale un événement de clé de test.
- Répondez `2xx` sous 5 secondes. Sinon, nous réessayons après 1, 3 et 10 secondes, puis arrêtons — le statut reste
  toujours disponible via `GET /v1/verify/{id}`.
- Un événement peut arriver deux fois : ignorez les doublons par `id`.
- Chaque envoi et la réponse de votre serveur sont dans le tableau de bord : « Paramètres → API » → « Journal de
  livraison » de la clé.

## Signature du webhook

Chaque webhook est signé avec le secret de la clé — `whsec_…` dans « Paramètres → API ». Vérifiez la signature
pour vous assurer que la requête vient de Telvera et n’a pas été modifiée en route.

La signature arrive dans l’en-tête `X-Telvera-Signature`, par exemple `t=1791473899,v1=5f2b9c…` :

- `t` — heure d’envoi, en secondes unix ;
- `v1` — HMAC-SHA256 de la chaîne `{t}.{corps de la requête}`, avec votre secret comme clé, en hex.

Comment vérifier :

1. Prenez le corps de la requête **tel que reçu, octet par octet** — avant d’analyser le JSON.
2. Calculez le HMAC-SHA256 de `{t}.{corps}` avec votre secret.
3. Comparez le résultat à `v1` avec une comparaison à temps constant.
4. Rejetez la requête si `t` s’écarte de l’heure actuelle de plus de 5 minutes : cela empêche de rejouer une
   requête interceptée. Nos nouvelles tentatives de livraison sont signées à nouveau avec un `t` frais et passent.

**Après le renouvellement du secret**, nous signons encore les webhooks avec l’ancien secret pendant 24 heures.
L’en-tête contient alors deux `v1` : `t=…,v1=<signature avec le nouveau>,v1=<signature avec l’ancien>`. Acceptez la
requête si l’un ou l’autre correspond : ainsi aucun webhook n’est perdu pendant que vous mettez à jour le secret sur
votre serveur. Les exemples de code en tiennent déjà compte.

```php
<?php
function telvera_signature_valid(string $header, string $body, string $secret): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$name, $value] = array_pad(explode('=', $part, 2), 2, '');
        if ($name === 't' && ctype_digit($value)) {
            $t = (int) $value;
        } elseif ($name === 'v1') {
            $signatures[] = $value;
        }
    }
    if ($t === null || abs(time() - $t) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $body, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

$body = file_get_contents('php://input');
if (! telvera_signature_valid($_SERVER['HTTP_X_TELVERA_SIGNATURE'] ?? '', $body, getenv('TELVERA_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
$event = json_decode($body, true);
```
```js
import crypto from 'node:crypto';

// rawBody — le corps tel que reçu (express.raw({ type: 'application/json' }))
function signatureValid(header = '', rawBody, secret) {
  const parts = header.split(',').map((part) => part.split('='));
  const t = Number(parts.find(([name]) => name === 't')?.[1]);
  const signatures = parts.filter(([name]) => name === 'v1').map(([, value]) => value);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const want = Buffer.from(expected);
  return signatures.some((s) => {
    const got = Buffer.from(s);
    return got.length === want.length && crypto.timingSafeEqual(got, want);
  });
}
```
```python
import hashlib
import hmac
import time


def signature_valid(header: str, body: bytes, secret: str) -> bool:
    parts = [part.split("=", 1) for part in (header or "").split(",") if "=" in part]
    t = next((value for name, value in parts if name == "t"), "")
    signatures = [value for name, value in parts if name == "v1"]
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)
```
```csharp
using System.Security.Cryptography;
using System.Text;

// body — le corps tel que reçu, octet par octet
static bool SignatureValid(string? header, string body, string secret)
{
    long? t = null;
    var signatures = new List<string>();
    foreach (var part in (header ?? "").Split(','))
    {
        var kv = part.Split('=', 2);
        if (kv.Length != 2) continue;
        if (kv[0] == "t" && long.TryParse(kv[1], out var ts)) t = ts;
        else if (kv[0] == "v1") signatures.Add(kv[1]);
    }
    if (t is null || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t.Value) > 300) return false;

    var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{body}"));
    var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hash).ToLowerInvariant());
    return signatures.Any(s => CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(s)));
}
```
```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

// body — le corps tel que reçu, octet par octet
static boolean signatureValid(String header, String body, String secret) throws Exception {
    Long t = null;
    var signatures = new ArrayList<String>();
    for (var part : (header == null ? "" : header).split(",")) {
        var kv = part.split("=", 2);
        if (kv.length != 2) continue;
        if (kv[0].equals("t") && kv[1].matches("\\d{1,12}")) t = Long.parseLong(kv[1]);
        else if (kv[0].equals("v1")) signatures.add(kv[1]);
    }
    if (t == null || Math.abs(System.currentTimeMillis() / 1000 - t) > 300) return false;

    var mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    var expected = HexFormat.of().formatHex(mac.doFinal((t + "." + body).getBytes(StandardCharsets.UTF_8))).getBytes(StandardCharsets.UTF_8);
    for (var s : signatures) {
        if (MessageDigest.isEqual(expected, s.getBytes(StandardCharsets.UTF_8))) return true;
    }
    return false;
}
```
```go
// body — le corps tel que reçu, octet par octet
func signatureValid(header string, body []byte, secret string) bool {
	var t int64
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		name, value, ok := strings.Cut(part, "=")
		if !ok {
			continue
		}
		switch name {
		case "t":
			t, _ = strconv.ParseInt(value, 10, 64)
		case "v1":
			signatures = append(signatures, value)
		}
	}
	if t == 0 || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.", t)
	mac.Write(body)
	expected := []byte(hex.EncodeToString(mac.Sum(nil)))
	for _, s := range signatures {
		if hmac.Equal(expected, []byte(s)) {
			return true
		}
	}
	return false
}
```
```ruby
require "openssl"

# body — le corps tel que reçu, octet par octet
def signature_valid?(header, body, secret)
  parts = header.to_s.split(",").filter_map { |part| part.split("=", 2) if part.include?("=") }
  t = parts.find { |name, _| name == "t" }&.last.to_s
  signatures = parts.select { |name, _| name == "v1" }.map(&:last)
  return false unless t.match?(/\A\d{1,12}\z/) && (Time.now.to_i - t.to_i).abs <= 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{body}")
  signatures.any? { |s| OpenSSL.secure_compare(expected, s) }
end
```

# Mise en production

## Bac à sable

Une clé de test `sk_test_…` (créée dans « Paramètres → API ») fonctionne comme une clé réelle, mais :

- l’appel est réel, et vous ne pouvez vérifier que le numéro de votre compte ;
- les vérifications ne sont pas déduites du pack ;
- les webhooks arrivent exactement comme en production.

Dans la section [« Sandbox »](https://app.telvera.io/fr/sandbox) du tableau de bord, vous pouvez parcourir tout le parcours sans code et voir les vraies requêtes et réponses.

## Support

Ouvrez une demande dans le tableau de bord : [« Support → Nouvelle demande »](https://app.telvera.io/fr/support/create). Indiquez l’en-tête
`X-Request-Id` de la réponse de l’API — il nous permet de retrouver la requête, l’appel et le webhook.
