# Telvera API

# Erste Schritte

## Überblick

Telvera bestätigt eine Telefonnummer **per eingehendem Anruf** statt per SMS-Code. Sie zeigen dem Nutzer eine Nummer,
er ruft sie von seinem Telefon aus an, wir legen ohne Annahme auf (für den Nutzer kostenlos) und melden Ihrem Server,
dass die Nummer bestätigt ist.

Die API besteht aus zwei Anfragen und einem Webhook:

1. `POST /v1/verify` — Sie senden die Nummer des Nutzers und erhalten `call_to`, die Nummer, die er anrufen soll.
2. Der Nutzer ruft `call_to` von seinem Telefon aus an.
3. Wir senden den Webhook `verification.verified` (oder Sie fragen den Status mit `GET /v1/verify/{id}` ab).

Anfragen und Antworten sind JSON, Zeiten ISO 8601 in UTC, Nummern in
Antworten E.164.

## Schnellstart

**1. Holen Sie sich einen Schlüssel** im Dashboard: „Einstellungen → API“. Senden Sie ihn im Header
`Authorization: Bearer sk_live_…`. Bewahren Sie ihn nur auf Ihrem Server auf.

**2. Fordern Sie eine Nummer zum Anrufen an:**

```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']; // dem Nutzer anzeigen
```
```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(() => ({})); // ein Proxy kann HTML liefern
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);

console.log(body.call_to); // dem Nutzer anzeigen
```
```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öst eine Ausnahme aus; Fehlercode in response.json()["error"]["code"]
verification = response.json()

print(verification["call_to"])  # dem Nutzer anzeigen
```
```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()); // dem Nutzer anzeigen
```
```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(); // Body ist JSON mit call_to; mit Jackson oder Gson parsen
```
```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) // dem Nutzer anzeigen
```
```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"] # dem Nutzer anzeigen
```

```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. Zeigen Sie dem Nutzer `call_to`** — als `tel:`-Button auf dem Handy, als QR-Code am Computer — mit einem Countdown bis `expires_at`.

**4. Ergebnis erhalten.** Hinterlegen Sie im Dashboard eine Webhook-URL am Schlüssel — nach dem Anruf kommt dort
`verification.verified` an; [prüfen Sie die Signatur](#signature). Ohne Webhook fragen Sie `GET /v1/verify/{id}`
alle 2–3 Sekunden ab, solange der Status `pending` ist.

# Referenz

## Das Prüfungsobjekt

Beide Methoden geben eine Prüfung zurück, und der Webhook enthält sie in `data`.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | string | Kennung: `vrf_` und 32 Hex-Zeichen |
| `status` | string | `pending` — wartet auf den Anruf, `verified` — Nummer bestätigt, `expired` — kein Anruf |
| `phone` | string | Nummer des Nutzers, E.164 |
| `call_to` | string | Nummer, die der Nutzer anrufen soll, E.164 |
| `expires_at` | string | Bis wann wir auf den Anruf warten: eine Minute nach Erstellung |
| `verified_at` | string \| null | Wann der Anruf gezählt wurde |
| `metadata` | object \| null | Ihre Daten aus der Anfrage, unverändert: dasselbe Objekt, dieselben Typen |
| `created_at` | string | Wann sie erstellt wurde |

`verified` und `expired` sind endgültig. Ein rechtzeitiger Anruf kann uns verzögert erreichen, daher bleibt die Prüfung noch 35 Sekunden nach `expires_at` auf `pending` und kann noch bestätigt werden — es sei denn, Sie erstellen in dieser Zeit eine neue für diese Nummer.

```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"
}
```

## Prüfung erstellen

`POST /v1/verify`

Gibt die Nummer zurück, die der Nutzer anrufen soll — er hat eine Minute Zeit. Eine Prüfung wird erst vom Paket abgezogen, wenn der Status `verified` wird.

### Body-Parameter

| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `phone` | string | ja | Nummer des Nutzers im internationalen Format; Leerzeichen, Klammern und Bindestriche sind erlaubt. Fehlt sie oder ist sie nicht erkennbar — `invalid_phone` |
| `metadata` | object | nein | Flaches Objekt mit bis zu 20 Feldern. Schlüssel — String bis 64 Zeichen; Wert — String bis 500 Zeichen, Zahl, `true`/`false` oder `null`. Kommt in Antworten und im Webhook zurück |
| `Idempotency-Key` | Header | nein | Wiederholungsschlüssel – einer pro Sitzung/Versuch des Nutzers, z. B. `signup-<Sitzungs-ID>`. Eine Wiederholung mit demselben Schlüssel liefert dieselbe Prüfung. 1–255 druckbare ASCII-Zeichen |

### Antwort

`201 Created` und eine neue [Prüfung](#object) mit Status `pending`. Eine Wiederholung mit demselben `Idempotency-Key` — `200 OK`, Header `Idempotent-Replayed: true` und **dieselbe** Prüfung.

### Fehler

| HTTP | `code` | Wann |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Schlüssel fehlt, ist falsch oder widerrufen |
| 402 | [`insufficient_checks`](#error-insufficient-checks) | Keine Prüfungen mehr im Paket |
| 403 | [`account_disabled`](#error-account-disabled) | Das Konto ist gesperrt |
| 403 | [`test_phone_only`](#error-test-phone-only) | Testschlüssel mit fremder Nummer |
| 422 | [`invalid_phone`](#error-invalid-phone) | `phone` fehlt oder ist nicht erkennbar |
| 422 | [`phone_not_supported`](#error-phone-not-supported) | Nummern dieses Landes werden noch nicht unterstützt; eine Wiederholung hilft nicht |
| 422 | [`idempotency_key_reused`](#error-idempotency-key-reused) | Derselbe `Idempotency-Key` wurde bereits mit einer anderen Nummer verwendet |
| 422 | [`invalid_request`](#error-invalid-request) | Der Body ist kein JSON, `metadata` ist kein flaches Objekt oder hat mehr als 20 Felder |
| 429 | [`rate_limited`](#error-rate-limited) | Mehr als 120 Anfragen pro Minute pro Schlüssel oder mehr als 5 neue Prüfungen derselben Nummer in 10 Minuten |
| 503 | [`unavailable`](#error-unavailable) | Gerade keine freie Nummer; nach `Retry-After` Sekunden erneut versuchen |

**Wiederholungen.** Senden Sie einen an die Sitzung des Nutzers gebundenen `Idempotency-Key`. Eine Wiederholung mit
demselben Schlüssel innerhalb von 24 Stunden (Doppelklick, Netzabbruch) liefert dieselbe Prüfung — nichts wird
berechnet, das Limit wird nicht verbraucht. Ohne Schlüssel erzeugt jede Anfrage eine neue Prüfung: Wiederholungen
anhand der Nummer erkennen wir nicht, sonst könnte eine fremde Sitzung Ihre Prüfung erhalten.

**Andere Nummer.** Kommt der Nutzer nicht durch, erstellen Sie die Prüfung mit einem neuen Schlüssel erneut. Sie
erhalten eine andere `call_to`, die vorherige Prüfung wird sofort `expired` (mit Webhook); Anrufe auf ihre Nummer
zählen nicht mehr.

```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(() => ({})); // ein Proxy kann HTML liefern
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(); // Body ist JSON mit call_to; mit Jackson oder Gson parsen
```
```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"
}
```

## Status abrufen

`GET /v1/verify/{id}`

Gibt die Prüfung mit ihrem aktuellen Status zurück. Fragen Sie höchstens alle 2 Sekunden ab, solange der Status
`pending` ist; bei hohem Volumen nutzen Sie den [Webhook](#webhooks).

### Pfadparameter

| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | string | ja | Die Prüfungs-ID aus der Antwort von `POST /v1/verify` |

### Antwort

`200 OK` und ein [Prüfungsobjekt](#object).

### Fehler

| HTTP | `code` | Wann |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Schlüssel fehlt, ist falsch oder widerrufen |
| 403 | [`account_disabled`](#error-account-disabled) | Das Konto ist gesperrt |
| 404 | [`not_found`](#error-not-found) | Diese Prüfung gibt es nicht oder sie gehört zu einem anderen Konto |
| 429 | [`rate_limited`](#error-rate-limited) | Mehr als 3000 Statusabfragen pro Minute pro Schlüssel |

```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(() => ({})); // ein Proxy kann HTML liefern
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"
}
```

## Fehler

Ein Fehler hat immer dieselbe Form: `{"error": {"code", "message"}}`. Werten Sie ihn anhand von `code` aus — er ändert sich nie; `message` ist eine Erklärung für Entwickler.

| HTTP | `code` | Was tun |
|---|---|---|
| 401 | `unauthorized` | Schlüssel unter „Einstellungen → API“ prüfen |
| 402 | `insufficient_checks` | Paket im Dashboard kaufen |
| 403 | `account_disabled` | Konto gesperrt – wenden Sie sich an den Support; der Schlüssel muss nicht geändert werden |
| 403 | `test_phone_only` | Live-Schlüssel oder die Nummer Ihres Kontos verwenden |
| 404 | `not_found` | `id` prüfen |
| 405 | `invalid_request` | Methode unter dieser URL nicht unterstützt (z. B. `DELETE /v1/verify/vrf_…`). Eine unbekannte URL ergibt `404` |
| 422 | `invalid_phone` | Nummer im internationalen Format senden |
| 422 | `phone_not_supported` | Nummern dieses Landes werden noch nicht unterstützt. Nicht wiederholen – bieten Sie einen anderen Weg an |
| 422 | `idempotency_key_reused` | Für jeden Versuch des Nutzers einen neuen `Idempotency-Key` verwenden |
| 422 | `invalid_request` | Anfrage-Body korrigieren, Details in `message` |
| 429 | `rate_limited` | Nach `Retry-After` Sekunden erneut versuchen |
| 500 | `internal_error` | Erneut versuchen; falls es weiter fehlschlägt, Support mit `X-Request-Id` kontaktieren |
| 503 | `unavailable` | Nach `Retry-After` Sekunden erneut versuchen |

**Limits** pro Schlüssel, unabhängig: `POST /v1/verify` — 120 pro Minute und höchstens 5 neue Prüfungen einer
Nummer pro 10 Minuten (eine Wiederholung mit demselben `Idempotency-Key` zählt nicht); `GET /v1/verify/{id}` — 3000
pro Minute.

**Kompatibilität.** Innerhalb von `v1` können wir Antwortfelder, Ereignistypen und Fehlercodes hinzufügen. Lehnen Sie
eine Antwort nicht wegen eines unbekannten Felds ab und behandeln Sie unbekannte Ereignisse und Codes als allgemeinen Fall.

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


# Webhooks

## Webhooks

Hinterlegen Sie die URL am Schlüssel im Dashboard („Einstellungen → API → Bearbeiten“). Wenn eine Prüfung endet,
senden wir dorthin einen `POST` mit einem Ereignis — eines pro Prüfung:

| Ereignis | Wann |
|---|---|
| `verification.verified` | Die Nummer ist bestätigt |
| `verification.expired` | Kein Anruf. Kommt 35–95 Sekunden nach `expires_at` oder sofort, wenn für die Nummer eine neue Prüfung erstellt wird |

```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` ist die [Prüfung](#object) zum Zeitpunkt des Ereignisses; `livemode: false` kennzeichnet ein Ereignis eines Testschlüssels.
- Antworten Sie innerhalb von 5 Sekunden mit `2xx`. Sonst wiederholen wir nach 1, 3 und 10 Sekunden und hören dann auf —
  der Status ist jederzeit über `GET /v1/verify/{id}` abrufbar.
- Ein Ereignis kann doppelt kommen: Duplikate anhand von `id` überspringen.
- Jede Zustellung und die Antwort Ihres Servers stehen im Dashboard: „Einstellungen → API“ → „Zustellprotokoll“ am Schlüssel.

## Webhook-Signatur

Jeder Webhook ist mit dem Secret des Schlüssels signiert — `whsec_…` unter „Einstellungen → API“. Prüfen Sie die
Signatur, um sicherzugehen, dass die Anfrage von Telvera stammt und unterwegs nicht verändert wurde.

Die Signatur kommt im Header `X-Telvera-Signature`, z. B. `t=1791473899,v1=5f2b9c…`:

- `t` — Sendezeitpunkt in Unix-Sekunden;
- `v1` — HMAC-SHA256 der Zeichenkette `{t}.{Request-Body}` mit Ihrem Secret als Schlüssel, hex-kodiert.

So prüfen Sie:

1. Nehmen Sie den Request-Body **genau wie empfangen, Byte für Byte** — vor dem Parsen des JSON.
2. Berechnen Sie HMAC-SHA256 von `{t}.{Body}` mit Ihrem Secret.
3. Vergleichen Sie das Ergebnis mit `v1` per Vergleich in konstanter Zeit.
4. Lehnen Sie die Anfrage ab, wenn `t` mehr als 5 Minuten von der aktuellen Zeit abweicht — das schützt davor,
   dass eine abgefangene Anfrage erneut gesendet wird. Unsere Zustellwiederholungen werden mit frischem `t` neu
   signiert und kommen durch.

**Nach dem Erneuern des Secrets** signieren wir Webhooks 24 Stunden lang zusätzlich mit dem bisherigen Secret. Der
Header enthält dann zwei `v1`: `t=…,v1=<Signatur mit neuem>,v1=<Signatur mit bisherigem>`. Akzeptieren Sie die
Anfrage, wenn einer davon passt — so gehen keine Webhooks verloren, während Sie das Secret auf Ihrem Server
aktualisieren. Die Codebeispiele berücksichtigen das bereits.

```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 — der Body wie empfangen (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 — der Body genau wie empfangen
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 — der Body genau wie empfangen
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 — der Body genau wie empfangen
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 — der Body genau wie empfangen
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
```

# Livegang

## Sandbox

Ein Testschlüssel `sk_test_…` (erstellt unter „Einstellungen → API“) funktioniert wie ein Live-Schlüssel, aber:

- der Anruf ist echt, geprüft werden kann nur die Nummer Ihres Kontos;
- Prüfungen werden nicht vom Paket abgezogen;
- Webhooks kommen genauso wie im Live-Modus.

Im Bereich [„Sandbox“](https://app.telvera.io/de/sandbox) des Dashboards können Sie den ganzen Ablauf ohne Code durchgehen und die echten Anfragen und Antworten sehen.

## Support

Erstellen Sie eine Anfrage im Dashboard: [„Support → Neue Anfrage“](https://app.telvera.io/de/support/create). Geben Sie den Header
`X-Request-Id` aus der API-Antwort an — damit finden wir Anfrage, Anruf und Webhook.
