Telvera / Documentación

Verificación de teléfono por llamada entrante: peticiones, respuestas, webhooks

URL base https://api.telvera.io

Primeros pasos

Descripción general

Telvera confirma un número de teléfono con una llamada entrante en lugar de un código por SMS. Muestras un número al usuario, él lo llama desde su teléfono, colgamos sin contestar (gratis para el usuario) y avisamos a tu servidor de que el número está verificado.

La API son dos solicitudes y un webhook:

  1. POST /v1/verify — envías el número del usuario y recibes call_to, el número al que debe llamar.
  2. El usuario llama a call_to desde su teléfono.
  3. Enviamos el webhook verification.verified (o consultas el estado con GET /v1/verify/{id}).

Solicitudes y respuestas en JSON, horas en ISO 8601 (UTC), números en las respuestas en E.164.

Inicio rápido

1. Obtén una clave en el panel: «Ajustes → API». Envíala en la cabecera Authorization: Bearer sk_live_…. Guárdala solo en tu servidor.

2. Solicita un número para llamar:

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']; // muéstralo al usuario
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 puede devolver HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);

console.log(body.call_to); // muéstralo al usuario
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 lanza excepción; el código está en response.json()["error"]["code"]
verification = response.json()

print(verification["call_to"])  # muéstralo al usuario
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()); // muéstralo al usuario
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(); // el cuerpo es JSON con call_to; analízalo con Jackson o 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) // muéstralo al usuario
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"] # muéstralo al usuario
Respuesta · 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. Muestra call_to al usuario — como botón tel: en el móvil o código QR en el ordenador — con una cuenta atrás hasta expires_at.

4. Obtén el resultado. Indica la URL del webhook en la clave desde el panel: tras la llamada recibirá verification.verified; verifica su firma. Sin webhook, consulta GET /v1/verify/{id} cada 2–3 segundos mientras el estado sea pending.

Referencia

El objeto verificación

Ambos métodos devuelven una verificación, y el webhook la incluye en data.

idstring
Identificador: vrf_ y 32 caracteres hex
statusstring
pending — esperando la llamada, verified — número confirmado, expired — no hubo llamada
phonestring
Número del usuario, E.164
call_tostring
Número al que debe llamar el usuario, E.164
expires_atstring
Hasta cuándo esperamos la llamada: un minuto desde la creación
verified_atstring | null
Cuándo se contó la llamada
metadataobject | null
Tus datos de la solicitud, sin cambios: el mismo objeto y los mismos tipos
created_atstring
Cuándo se creó

verified y expired son definitivos. Una llamada hecha a tiempo puede llegarnos con retraso, por eso durante 35 segundos más tras expires_at la verificación sigue en pending y aún puede confirmarse, salvo que entretanto crees una nueva para ese número.

El objeto verificación
{
  "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"
}

Crear una verificación

POST/v1/verify

Devuelve el número al que debe llamar el usuario; tiene un minuto. La verificación se descuenta del paquete solo cuando el estado pasa a verified.

Parámetros del cuerpo

phonestringobligatorio
Número del usuario en formato internacional; se admiten espacios, paréntesis y guiones. Si falta o no se reconoce — invalid_phone
metadataobject
Objeto plano de hasta 20 campos. Clave: cadena de hasta 64 caracteres; valor: cadena de hasta 500 caracteres, número, true/false o null. Se devuelve en las respuestas y en el webhook
Idempotency-Keycabecera
Clave de repetición: una por sesión o intento del usuario, p. ej. signup-<id de sesión>. Repetir con la misma clave devuelve la misma verificación. 1–255 caracteres ASCII imprimibles

Respuesta

201 Created y una nueva verificación con estado pending. Una repetición con el mismo Idempotency-Key: 200 OK, cabecera Idempotent-Replayed: true y la misma verificación.

Errores

HTTP code Cuándo
401 unauthorized Falta la clave, es incorrecta o está revocada
402 insufficient_checks No quedan verificaciones en el paquete
403 account_disabled La cuenta está bloqueada
403 test_phone_only Clave de prueba con un número ajeno
422 invalid_phone Falta phone o no se reconoce
422 phone_not_supported Aún no admitimos números de este país; reintentar no ayuda
422 idempotency_key_reused El mismo Idempotency-Key ya se usó con otro número
422 invalid_request El cuerpo no es JSON, metadata no es un objeto plano o tiene más de 20 campos
429 rate_limited Más de 120 solicitudes por minuto por clave o más de 5 verificaciones nuevas del mismo número cada 10 minutos
503 unavailable Ahora no hay un número libre; reintenta tras Retry-After segundos

Repeticiones. Envía un Idempotency-Key ligado a la sesión del usuario. Repetir con la misma clave durante 24 horas (doble clic, corte de red) devuelve la misma verificación: no se cobra nada ni se gasta el límite. Sin clave, cada solicitud crea una verificación nueva: no detectamos repeticiones por número, si no otra sesión podría recibir tu verificación.

Otro número. Si el usuario no logró llamar, crea la verificación de nuevo con una clave nueva. Recibirás otro call_to y la anterior pasa a expired de inmediato (con webhook); las llamadas a su número ya no cuentan.

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 puede devolver 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(); // el cuerpo es JSON con call_to; analízalo con Jackson o 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)
Respuesta · 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"
}

Obtener el estado

GET/v1/verify/{id}

Devuelve la verificación con su estado actual. Consulta como mucho una vez cada 2 segundos mientras el estado sea pending; con mucho volumen, usa el webhook.

Parámetros de ruta

idstringobligatorio
ID de la verificación de la respuesta de POST /v1/verify

Respuesta

200 OK y un objeto verificación.

Errores

HTTP code Cuándo
401 unauthorized Falta la clave, es incorrecta o está revocada
403 account_disabled La cuenta está bloqueada
404 not_found No existe la verificación o pertenece a otra cuenta
429 rate_limited Más de 3000 consultas de estado por minuto por clave
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 puede devolver 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)
Respuesta · 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"
}

Errores

Un error siempre tiene la misma forma: {"error": {"code", "message"}}. Gestiónalo por code, que nunca cambia; message es una explicación para el desarrollador.

HTTP code Qué hacer
401 unauthorized Revisa la clave en «Ajustes → API»
402 insufficient_checks Compra un paquete en el panel
403 account_disabled La cuenta está bloqueada: escribe a soporte; no hace falta cambiar la clave
403 test_phone_only Usa una clave real o el número de tu cuenta
404 not_found Revisa el id
405 invalid_request Método no admitido en esta URL (p. ej. DELETE /v1/verify/vrf_…). Una URL desconocida devuelve 404
422 invalid_phone Envía el número en formato internacional
422 phone_not_supported Aún no admitimos números de este país. No reintentes: ofrece al usuario otra forma
422 idempotency_key_reused Usa un Idempotency-Key nuevo en cada intento del usuario
422 invalid_request Corrige el cuerpo de la solicitud, detalles en message
429 rate_limited Reintenta tras Retry-After segundos
500 internal_error Reintenta; si sigue fallando, escribe a soporte con el X-Request-Id
503 unavailable Reintenta tras Retry-After segundos

Límites por clave, independientes: POST /v1/verify — 120 por minuto y como máximo 5 verificaciones nuevas de un número cada 10 minutos (una repetición con el mismo Idempotency-Key no cuenta); GET /v1/verify/{id} — 3000 por minuto.

Compatibilidad. Dentro de v1 podemos añadir campos a las respuestas, tipos de eventos y códigos de error. No rechaces una respuesta por un campo desconocido y trata los eventos y códigos desconocidos como el caso general.

Respuesta · 402 Payment Required
{
  "error": {
    "code": "insufficient_checks",
    "message": "No verifications left. Buy a package in the dashboard."
  }
}

Webhooks

Webhooks

Indica la URL en la clave desde el panel («Ajustes → API → Editar»). Cuando una verificación termina, le enviamos un POST con un evento, uno por verificación:

Evento Cuándo
verification.verified El número está confirmado
verification.expired No hubo llamada. Llega 35–95 segundos después de expires_at o de inmediato si se crea una nueva verificación para el número
  • data es la verificación en el momento del evento; livemode: false indica un evento de clave de prueba.
  • Responde 2xx en 5 segundos. Si no, reintentamos tras 1, 3 y 10 segundos y paramos; el estado siempre se puede consultar con GET /v1/verify/{id}.
  • Un evento puede llegar dos veces: descarta los duplicados por id.
  • Cada envío y la respuesta de tu servidor están en el panel: «Ajustes → API» → «Registro de entregas» de la clave.
Cuerpo de la solicitud
{
  "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"
  }
}

Firma del webhook

Cada webhook se firma con el secreto de la clave: whsec_… de «Ajustes → API». Verifica la firma para asegurarte de que la solicitud la envió Telvera y no se modificó por el camino.

La firma llega en la cabecera X-Telvera-Signature, por ejemplo t=1791473899,v1=5f2b9c…:

  • t — hora de envío, en segundos unix;
  • v1 — HMAC-SHA256 de la cadena {t}.{cuerpo de la solicitud}, con tu secreto como clave, en hex.

Cómo verificarla:

  1. Toma el cuerpo de la solicitud tal como llega, byte a byte, antes de analizar el JSON.
  2. Calcula el HMAC-SHA256 de {t}.{cuerpo} con tu secreto.
  3. Compara el resultado con v1 usando una comparación de tiempo constante.
  4. Rechaza la solicitud si t difiere de la hora actual en más de 5 minutos: así te proteges de que alguien reenvíe una solicitud interceptada. Nuestros reintentos de entrega se firman de nuevo con un t actual, así que pasan.

Después de renovar el secreto, durante 24 horas seguimos firmando los webhooks también con el secreto anterior. La cabecera lleva entonces dos v1: t=…,v1=<firma con el nuevo>,v1=<firma con el anterior>. Acepta la solicitud si coincide cualquiera de ellos: así no pierdes webhooks mientras actualizas el secreto en tu servidor. Los ejemplos de código ya lo tienen en cuenta.

<?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 — el cuerpo tal como llega (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 — el cuerpo tal como llega, byte a byte
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 — el cuerpo tal como llega, byte a byte
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 — el cuerpo tal como llega, byte a byte
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 — el cuerpo tal como llega, byte a byte
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

Puesta en marcha

Entorno de pruebas

Una clave de prueba sk_test_… (se crea en «Ajustes → API») funciona como una real, pero:

  • la llamada es real y solo puedes verificar el número de tu cuenta;
  • las verificaciones no se descuentan del paquete;
  • los webhooks llegan igual que en modo real.

En la sección «Sandbox» del panel puedes recorrer todo el flujo sin código y ver las solicitudes y respuestas reales.

Soporte

Abre una solicitud en el panel: «Soporte → Nueva solicitud». Indica la cabecera X-Request-Id de la respuesta de la API: con ella encontraremos la solicitud, la llamada y el webhook.

TelveraVerificación de teléfono por llamada entrante: peticiones, respuestas, webhooks
Iniciar sesión