Telvera / Dokumentation

Telefonverifizierung per eingehendem Anruf: Anfragen, Antworten, Webhooks

Basis-URL https://api.telvera.io

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:

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
$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
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
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
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
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
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
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
Antwort · 201 Created
{
  "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. 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.

idstring
Kennung: vrf_ und 32 Hex-Zeichen
statusstring
pending — wartet auf den Anruf, verified — Nummer bestätigt, expired — kein Anruf
phonestring
Nummer des Nutzers, E.164
call_tostring
Nummer, die der Nutzer anrufen soll, E.164
expires_atstring
Bis wann wir auf den Anruf warten: eine Minute nach Erstellung
verified_atstring | null
Wann der Anruf gezählt wurde
metadataobject | null
Ihre Daten aus der Anfrage, unverändert: dasselbe Objekt, dieselben Typen
created_atstring
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.

Das Prüfungsobjekt
{
  "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

phonestringpflicht
Nummer des Nutzers im internationalen Format; Leerzeichen, Klammern und Bindestriche sind erlaubt. Fehlt sie oder ist sie nicht erkennbar — invalid_phone
metadataobject
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-KeyHeader
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 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 Schlüssel fehlt, ist falsch oder widerrufen
402 insufficient_checks Keine Prüfungen mehr im Paket
403 account_disabled Das Konto ist gesperrt
403 test_phone_only Testschlüssel mit fremder Nummer
422 invalid_phone phone fehlt oder ist nicht erkennbar
422 phone_not_supported Nummern dieses Landes werden noch nicht unterstützt; eine Wiederholung hilft nicht
422 idempotency_key_reused Derselbe Idempotency-Key wurde bereits mit einer anderen Nummer verwendet
422 invalid_request Der Body ist kein JSON, metadata ist kein flaches Objekt oder hat mehr als 20 Felder
429 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 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.

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
$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']);
}
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}`);
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()
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());
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
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)
}
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)
Antwort · 201 Created
{
  "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.

Pfadparameter

idstringpflicht
Die Prüfungs-ID aus der Antwort von POST /v1/verify

Antwort

200 OK und ein Prüfungsobjekt.

Fehler

HTTP code Wann
401 unauthorized Schlüssel fehlt, ist falsch oder widerrufen
403 account_disabled Das Konto ist gesperrt
404 not_found Diese Prüfung gibt es nicht oder sie gehört zu einem anderen Konto
429 rate_limited Mehr als 3000 Statusabfragen pro Minute pro Schlüssel
curl https://api.telvera.io/v1/verify/vrf_0d9a3d2b5ca2439b9af861470d5986bc \
  -H "Authorization: Bearer $TELVERA_API_KEY"
<?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']);
}
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}`);
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()
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();
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());
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)
}
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)
Antwort · 200 OK
{
  "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.

Antwort · 402 Payment Required
{
  "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
  • data ist die Prüfung 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.
Anfrage-Body
{
  "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"
  }
}

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
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);
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);
  });
}
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)
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)));
}
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;
}
// 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
}
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“ 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“. Geben Sie den Header X-Request-Id aus der API-Antwort an — damit finden wir Anfrage, Anruf und Webhook.

TelveraTelefonverifizierung per eingehendem Anruf: Anfragen, Antworten, Webhooks
Anmelden