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 :
POST /v1/verify— vous envoyez le numéro de l’utilisateur et recevezcall_to, le numéro qu’il doit appeler.- L’utilisateur appelle
call_todepuis son téléphone. - Nous envoyons le webhook
verification.verified(ou vous consultez le statut viaGET /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 :
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']; // à afficher à l’utilisateur
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
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
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
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
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
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
{
"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. 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.
idstring- Identifiant :
vrf_suivi de 32 caractères hex statusstringpending— en attente de l’appel,verified— numéro confirmé,expired— pas d’appelphonestring- Numéro de l’utilisateur, E.164
call_tostring- Numéro que l’utilisateur doit appeler, E.164
expires_atstring- Jusqu’à quand nous attendons l’appel : une minute après la création
verified_atstring | null- Moment où l’appel a été pris en compte
metadataobject | null- Vos données de la requête, inchangées : même objet, mêmes types
created_atstring- 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.
{
"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
/v1/verifyRenvoie 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
phonestringobligatoire- Numéro de l’utilisateur au format international ; espaces, parenthèses et tirets acceptés. Absent ou non reconnu —
invalid_phone metadataobject- Objet plat de 20 champs maximum. Clé — chaîne jusqu’à 64 caractères ; valeur — chaîne jusqu’à 500 caractères, nombre,
true/falseounull. Renvoyé dans les réponses et le webhook Idempotency-Keyen-tête- 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 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 |
Clé absente, erronée ou révoquée |
| 402 | insufficient_checks |
Plus de vérifications dans le pack |
| 403 | account_disabled |
Compte bloqué |
| 403 | test_phone_only |
Clé de test avec le numéro de quelqu’un d’autre |
| 422 | invalid_phone |
phone absent ou non reconnu |
| 422 | 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 |
Le même Idempotency-Key a déjà servi avec un autre numéro |
| 422 | invalid_request |
Le corps n’est pas du JSON, metadata n’est pas un objet plat ou dépasse 20 champs |
| 429 | 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 |
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.
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(() => ({})); // un proxy peut renvoyer du HTML
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(); // le corps est du JSON avec call_to ; analysez-le avec Jackson ou Gson
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"
}
Obtenir le statut
/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.
Paramètres de chemin
idstringobligatoire- L’ID de vérification de la réponse de
POST /v1/verify
Réponse
200 OK et un objet vérification.
Erreurs
| HTTP | code |
Quand |
|---|---|---|
| 401 | unauthorized |
Clé absente, erronée ou révoquée |
| 403 | account_disabled |
Compte bloqué |
| 404 | not_found |
Cette vérification n’existe pas ou appartient à un autre compte |
| 429 | rate_limited |
Plus de 3000 requêtes de statut par minute par clé |
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(() => ({})); // un proxy peut renvoyer du HTML
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"
}
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.
{
"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 |
dataest la vérification au moment de l’événement ;livemode: falsesignale un événement de clé de test.- Répondez
2xxsous 5 secondes. Sinon, nous réessayons après 1, 3 et 10 secondes, puis arrêtons — le statut reste toujours disponible viaGET /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é.
{
"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"
}
}
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 :
- Prenez le corps de la requête tel que reçu, octet par octet — avant d’analyser le JSON.
- Calculez le HMAC-SHA256 de
{t}.{corps}avec votre secret. - Comparez le résultat à
v1avec une comparaison à temps constant. - Rejetez la requête si
ts’é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 untfrais 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
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 — 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);
});
}
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 — 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)));
}
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;
}
// 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
}
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 » 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 ». 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.