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:
POST /v1/verify— Sie senden die Nummer des Nutzers und erhaltencall_to, die Nummer, die er anrufen soll.- Der Nutzer ruft
call_tovon seinem Telefon aus an. - Wir senden den Webhook
verification.verified(oder Sie fragen den Status mitGET /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
{
"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 statusstringpending— wartet auf den Anruf,verified— Nummer bestätigt,expired— kein Anrufphonestring- 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.
{
"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
/v1/verifyGibt 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/falseodernull. 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)
{
"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
/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)
{
"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.
{
"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 |
dataist die Prüfung zum Zeitpunkt des Ereignisses;livemode: falsekennzeichnet 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 überGET /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.
{
"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:
- Nehmen Sie den Request-Body genau wie empfangen, Byte für Byte — vor dem Parsen des JSON.
- Berechnen Sie HMAC-SHA256 von
{t}.{Body}mit Ihrem Secret. - Vergleichen Sie das Ergebnis mit
v1per Vergleich in konstanter Zeit. - Lehnen Sie die Anfrage ab, wenn
tmehr als 5 Minuten von der aktuellen Zeit abweicht — das schützt davor, dass eine abgefangene Anfrage erneut gesendet wird. Unsere Zustellwiederholungen werden mit frischemtneu 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.