# Telvera API

# 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:**

```bash
curl https://api.telvera.io/v1/verify \
  -H "Authorization: Bearer $TELVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-7f3c2a" \
  -d '{"phone": "+375290000001"}'
```
```php
<?php
$ch = curl_init('https://api.telvera.io/v1/verify');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('TELVERA_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: signup-' . $sessionId,
    ],
    CURLOPT_POSTFIELDS     => json_encode(['phone' => '+375290000001']),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($status >= 400) {
    throw new RuntimeException($body['error']['code'] ?? 'http_' . $status);
}
echo $body['call_to']; // muéstralo al usuario
```
```js
const response = await fetch('https://api.telvera.io/v1/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TELVERA_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `signup-${sessionId}`,
  },
  body: JSON.stringify({ phone: '+375290000001' }),
});
const body = await response.json().catch(() => ({})); // 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
```
```python
import os
import requests

response = requests.post(
    "https://api.telvera.io/v1/verify",
    headers={
        "Authorization": f"Bearer {os.environ['TELVERA_API_KEY']}",
        "Idempotency-Key": f"signup-{session_id}",
    },
    json={"phone": "+375290000001"},
    timeout=10,
)
response.raise_for_status()  # 4xx/5xx lanza excepción; el código está en response.json()["error"]["code"]
verification = response.json()

print(verification["call_to"])  # muéstralo al usuario
```
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("TELVERA_API_KEY"));

var request = new HttpRequestMessage(HttpMethod.Post, "https://api.telvera.io/v1/verify")
{
    Content = JsonContent.Create(new { phone = "+375290000001" }),
};
request.Headers.Add("Idempotency-Key", $"signup-{sessionId}");
var response = await http.SendAsync(request);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
    throw new Exception(body.GetProperty("error").GetProperty("code").GetString());

Console.WriteLine(body.GetProperty("call_to").GetString()); // muéstralo al usuario
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

var request = HttpRequest.newBuilder(URI.create("https://api.telvera.io/v1/verify"))
    .header("Authorization", "Bearer " + System.getenv("TELVERA_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "signup-" + sessionId)
    .POST(HttpRequest.BodyPublishers.ofString("{\"phone\": \"+375290000001\"}"))
    .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());

String json = response.body(); // el cuerpo es JSON con call_to; analízalo con Jackson o Gson
```
```go
req, _ := http.NewRequest("POST", "https://api.telvera.io/v1/verify",
	strings.NewReader(`{"phone": "+375290000001"}`))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TELVERA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "signup-"+sessionID)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var v struct {
	ID     string `json:"id"`
	CallTo string `json:"call_to"`
	Error  struct{ Code string `json:"code"` } `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&v)
if resp.StatusCode >= 400 {
	log.Fatalf("telvera: %d %s", resp.StatusCode, v.Error.Code)
}
fmt.Println(v.CallTo) // muéstralo al usuario
```
```ruby
require "net/http"
require "json"

response = Net::HTTP.post(URI("https://api.telvera.io/v1/verify"), { phone: "+375290000001" }.to_json,
  "Authorization" => "Bearer #{ENV.fetch("TELVERA_API_KEY")}", "Content-Type" => "application/json",
  "Idempotency-Key" => "signup-#{session_id}")
body = JSON.parse(response.body) rescue {}
raise "telvera: #{body.dig("error", "code") || response.code}" unless response.is_a?(Net::HTTPSuccess)

puts body["call_to"] # muéstralo al usuario
```

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "pending",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": null,
  "metadata": null,
  "created_at": "2026-10-08T15:38:13Z"
}
```

**3. 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](#signature). 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`.

| Campo | Tipo | Descripción |
|---|---|---|
| `id` | string | Identificador: `vrf_` y 32 caracteres hex |
| `status` | string | `pending` — esperando la llamada, `verified` — número confirmado, `expired` — no hubo llamada |
| `phone` | string | Número del usuario, E.164 |
| `call_to` | string | Número al que debe llamar el usuario, E.164 |
| `expires_at` | string | Hasta cuándo esperamos la llamada: un minuto desde la creación |
| `verified_at` | string \| null | Cuándo se contó la llamada |
| `metadata` | object \| null | Tus datos de la solicitud, sin cambios: el mismo objeto y los mismos tipos |
| `created_at` | string | 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.

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "pending",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": null,
  "metadata": { "user_id": "42" },
  "created_at": "2026-10-08T15:38:13Z"
}
```

## 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

| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `phone` | string | sí | Número del usuario en formato internacional; se admiten espacios, paréntesis y guiones. Si falta o no se reconoce — `invalid_phone` |
| `metadata` | object | no | 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-Key` | cabecera | no | 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](#object) 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`](#error-unauthorized) | Falta la clave, es incorrecta o está revocada |
| 402 | [`insufficient_checks`](#error-insufficient-checks) | No quedan verificaciones en el paquete |
| 403 | [`account_disabled`](#error-account-disabled) | La cuenta está bloqueada |
| 403 | [`test_phone_only`](#error-test-phone-only) | Clave de prueba con un número ajeno |
| 422 | [`invalid_phone`](#error-invalid-phone) | Falta `phone` o no se reconoce |
| 422 | [`phone_not_supported`](#error-phone-not-supported) | Aún no admitimos números de este país; reintentar no ayuda |
| 422 | [`idempotency_key_reused`](#error-idempotency-key-reused) | El mismo `Idempotency-Key` ya se usó con otro número |
| 422 | [`invalid_request`](#error-invalid-request) | El cuerpo no es JSON, `metadata` no es un objeto plano o tiene más de 20 campos |
| 429 | [`rate_limited`](#error-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`](#error-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.

```bash
curl https://api.telvera.io/v1/verify \
  -H "Authorization: Bearer $TELVERA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-7f3c2a" \
  -d '{"phone": "+375290000001", "metadata": {"user_id": "42"}}'
```
```php
<?php
$ch = curl_init('https://api.telvera.io/v1/verify');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('TELVERA_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: signup-' . $sessionId,
    ],
    CURLOPT_POSTFIELDS     => json_encode(['phone' => '+375290000001', 'metadata' => ['user_id' => '42']]),
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
    throw new RuntimeException($body['error']['code']);
}
```
```js
const response = await fetch('https://api.telvera.io/v1/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TELVERA_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `signup-${sessionId}`,
  },
  body: JSON.stringify({ phone: '+375290000001', metadata: { user_id: '42' } }),
});
const body = await response.json().catch(() => ({})); // un proxy puede devolver HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);
```
```python
import os
import requests

response = requests.post(
    "https://api.telvera.io/v1/verify",
    headers={
        "Authorization": f"Bearer {os.environ['TELVERA_API_KEY']}",
        "Idempotency-Key": f"signup-{session_id}",
    },
    json={"phone": "+375290000001", "metadata": {"user_id": "42"}},
    timeout=10,
)
response.raise_for_status()
verification = response.json()
```
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("TELVERA_API_KEY"));

var request = new HttpRequestMessage(HttpMethod.Post, "https://api.telvera.io/v1/verify")
{
    Content = JsonContent.Create(new { phone = "+375290000001", metadata = new { user_id = "42" } }),
};
request.Headers.Add("Idempotency-Key", $"signup-{sessionId}");
var response = await http.SendAsync(request);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
    throw new Exception(body.GetProperty("error").GetProperty("code").GetString());
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

var request = HttpRequest.newBuilder(URI.create("https://api.telvera.io/v1/verify"))
    .header("Authorization", "Bearer " + System.getenv("TELVERA_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "signup-" + sessionId)
    .POST(HttpRequest.BodyPublishers.ofString("{\"phone\": \"+375290000001\", \"metadata\": {\"user_id\": \"42\"}}"))
    .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());

String json = response.body(); // el cuerpo es JSON con call_to; analízalo con Jackson o Gson
```
```go
req, _ := http.NewRequest("POST", "https://api.telvera.io/v1/verify",
	strings.NewReader(`{"phone": "+375290000001", "metadata": {"user_id": "42"}}`))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TELVERA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "signup-"+sessionID)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var v struct {
	ID     string `json:"id"`
	CallTo string `json:"call_to"`
	Error  struct{ Code string `json:"code"` } `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&v)
if resp.StatusCode >= 400 {
	log.Fatalf("telvera: %d %s", resp.StatusCode, v.Error.Code)
}
```
```ruby
require "net/http"
require "json"

response = Net::HTTP.post(URI("https://api.telvera.io/v1/verify"), { phone: "+375290000001", metadata: { user_id: "42" } }.to_json,
  "Authorization" => "Bearer #{ENV.fetch("TELVERA_API_KEY")}", "Content-Type" => "application/json",
  "Idempotency-Key" => "signup-#{session_id}")
body = JSON.parse(response.body) rescue {}
raise "telvera: #{body.dig("error", "code") || response.code}" unless response.is_a?(Net::HTTPSuccess)
```

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "pending",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": null,
  "metadata": { "user_id": "42" },
  "created_at": "2026-10-08T15:38:13Z"
}
```

## 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](#webhooks).

### Parámetros de ruta

| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `id` | string | sí | ID de la verificación de la respuesta de `POST /v1/verify` |

### Respuesta

`200 OK` y un [objeto verificación](#object).

### Errores

| HTTP | `code` | Cuándo |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Falta la clave, es incorrecta o está revocada |
| 403 | [`account_disabled`](#error-account-disabled) | La cuenta está bloqueada |
| 404 | [`not_found`](#error-not-found) | No existe la verificación o pertenece a otra cuenta |
| 429 | [`rate_limited`](#error-rate-limited) | Más de 3000 consultas de estado por minuto por clave |

```bash
curl https://api.telvera.io/v1/verify/vrf_0d9a3d2b5ca2439b9af861470d5986bc \
  -H "Authorization: Bearer $TELVERA_API_KEY"
```
```php
<?php
$ch = curl_init('https://api.telvera.io/v1/verify/' . $verificationId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('TELVERA_API_KEY')],
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
    throw new RuntimeException($body['error']['code']);
}
```
```js
const response = await fetch(`https://api.telvera.io/v1/verify/${verificationId}`, {
  headers: { Authorization: `Bearer ${process.env.TELVERA_API_KEY}` },
});
const body = await response.json().catch(() => ({})); // un proxy puede devolver HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);
```
```python
import os
import requests

response = requests.get(
    f"https://api.telvera.io/v1/verify/{verification_id}",
    headers={"Authorization": f"Bearer {os.environ['TELVERA_API_KEY']}"},
    timeout=10,
)
response.raise_for_status()
verification = response.json()
```
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("TELVERA_API_KEY"));

var response = await http.GetAsync($"https://api.telvera.io/v1/verify/{verificationId}");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
    throw new Exception(body.GetProperty("error").GetProperty("code").GetString());

var status = body.GetProperty("status").GetString();
```
```java
var request = HttpRequest.newBuilder(URI.create("https://api.telvera.io/v1/verify/" + verificationId))
    .header("Authorization", "Bearer " + System.getenv("TELVERA_API_KEY"))
    .build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
```
```go
req, _ := http.NewRequest("GET", "https://api.telvera.io/v1/verify/"+verificationID, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TELVERA_API_KEY"))

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()

var v struct {
	Status string `json:"status"`
	Error  struct{ Code string `json:"code"` } `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&v)
if resp.StatusCode >= 400 {
	log.Fatalf("telvera: %d %s", resp.StatusCode, v.Error.Code)
}
```
```ruby
uri = URI("https://api.telvera.io/v1/verify/#{verification_id}")
request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV.fetch("TELVERA_API_KEY")}")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
body = JSON.parse(response.body) rescue {}
raise "telvera: #{body.dig("error", "code") || response.code}" unless response.is_a?(Net::HTTPSuccess)
```

```json
{
  "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
  "status": "verified",
  "phone": "+375290000001",
  "call_to": "+375170000002",
  "expires_at": "2026-10-08T15:39:13Z",
  "verified_at": "2026-10-08T15:38:19Z",
  "metadata": { "user_id": "42" },
  "created_at": "2026-10-08T15:38:13Z"
}
```

## 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.

```json
{
  "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 |

```json
{
  "id": "evt_5b1f0c7e9a8d4c2f8e6a3b1d0c9f7e2a",
  "type": "verification.verified",
  "livemode": true,
  "created_at": "2026-10-08T15:38:19Z",
  "data": {
    "id": "vrf_0d9a3d2b5ca2439b9af861470d5986bc",
    "status": "verified",
    "phone": "+375290000001",
    "call_to": "+375170000002",
    "expires_at": "2026-10-08T15:39:13Z",
    "verified_at": "2026-10-08T15:38:19Z",
    "metadata": null,
    "created_at": "2026-10-08T15:38:13Z"
  }
}
```

- `data` es la [verificación](#object) 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.

## 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
<?php
function telvera_signature_valid(string $header, string $body, string $secret): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$name, $value] = array_pad(explode('=', $part, 2), 2, '');
        if ($name === 't' && ctype_digit($value)) {
            $t = (int) $value;
        } elseif ($name === 'v1') {
            $signatures[] = $value;
        }
    }
    if ($t === null || abs(time() - $t) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $body, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

$body = file_get_contents('php://input');
if (! telvera_signature_valid($_SERVER['HTTP_X_TELVERA_SIGNATURE'] ?? '', $body, getenv('TELVERA_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}
$event = json_decode($body, true);
```
```js
import crypto from 'node:crypto';

// rawBody — 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);
  });
}
```
```python
import hashlib
import hmac
import time


def signature_valid(header: str, body: bytes, secret: str) -> bool:
    parts = [part.split("=", 1) for part in (header or "").split(",") if "=" in part]
    t = next((value for name, value in parts if name == "t"), "")
    signatures = [value for name, value in parts if name == "v1"]
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)
```
```csharp
using System.Security.Cryptography;
using System.Text;

// body — 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)));
}
```
```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

// body — 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;
}
```
```go
// 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
}
```
```ruby
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»](https://app.telvera.io/es/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»](https://app.telvera.io/es/support/create). Indica la cabecera
`X-Request-Id` de la respuesta de la API: con ella encontraremos la solicitud, la llamada y el webhook.
