# Telvera API

# Начало работы

## Обзор

Telvera подтверждает номер телефона **входящим звонком** — вместо SMS с кодом. Вы показываете пользователю номер,
он звонит на него со своего телефона, мы сбрасываем вызов без ответа (для пользователя бесплатно) и сообщаем вашему
серверу, что номер подтверждён.

API — это два запроса и вебхук:

1. `POST /v1/verify` — передаёте номер пользователя, получаете `call_to` — номер, на который ему позвонить.
2. Пользователь звонит на `call_to` со своего телефона.
3. Мы присылаем вебхук `verification.verified` (или вы узнаёте статус через `GET /v1/verify/{id}`).

Запросы и ответы — JSON, время — ISO 8601 в UTC, номера в ответах — E.164.

## Быстрый старт

**1. Возьмите ключ** в кабинете: «Настройки → API». Ключ передаётся в заголовке `Authorization: Bearer sk_live_…`.
Храните его только на сервере.

**2. Запросите номер для звонка:**

```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']; // покажите пользователю
```
```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(() => ({})); // прокси может вернуть HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);

console.log(body.call_to); // покажите пользователю
```
```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 → исключение; код ошибки — response.json()["error"]["code"]
verification = response.json()

print(verification["call_to"])  # покажите пользователю
```
```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()); // покажите пользователю
```
```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(); // тело — JSON с call_to; разберите Jackson или 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) // покажите пользователю
```
```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"] # покажите пользователю
```

```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. Покажите `call_to` пользователю** — кнопкой `tel:` на телефоне, QR-кодом на компьютере — и таймер до `expires_at`.

**4. Получите результат.** Укажите адрес вебхука у ключа в кабинете — после звонка на него придёт
`verification.verified`; [проверяйте его подпись](#signature). Без вебхука — опрашивайте `GET /v1/verify/{id}` раз
в 2–3 секунды, пока статус `pending`.

# Справочник

## Объект проверки

Проверку возвращают оба метода, и она же лежит в `data` вебхука.

| Поле | Тип | Описание |
|---|---|---|
| `id` | string | Идентификатор: `vrf_` и 32 hex-символа |
| `status` | string | `pending` — ждём звонка, `verified` — номер подтверждён, `expired` — звонка не было |
| `phone` | string | Номер пользователя, E.164 |
| `call_to` | string | Номер, на который пользователь должен позвонить, E.164 |
| `expires_at` | string | До какого времени ждём звонок: минута от создания |
| `verified_at` | string \| null | Когда засчитан звонок |
| `metadata` | object \| null | Ваши данные из запроса, без изменений: тот же объект и те же типы значений |
| `created_at` | string | Когда создана |

`verified` и `expired` — окончательные статусы. Звонок, сделанный вовремя, может дойти до нас с задержкой, поэтому ещё 35 секунд после `expires_at` проверка остаётся `pending` и может подтвердиться — если за это время не создать по этому номеру новую.

```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"
}
```

## Создать проверку

`POST /v1/verify`

Выдаёт номер, на который пользователь должен позвонить, — на звонок даётся минута. Проверка списывается из пакета, только когда статус становится `verified`.

### Параметры запроса

| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| `phone` | string | да | Номер пользователя в международном формате; пробелы, скобки и дефисы допустимы. Нет поля или номер не распознан — `invalid_phone` |
| `metadata` | object | нет | Плоский объект до 20 полей. Ключ — строка до 64 символов, значение — строка до 500 символов, число, `true`/`false` или `null`. Вернём в ответах и вебхуке |
| `Idempotency-Key` | заголовок | нет | Ключ повтора — один на сеанс / попытку пользователя, например `signup-<id сеанса>`. Повтор с тем же ключом вернёт ту же проверку. 1–255 печатных ASCII-символов |

### Ответ

`201 Created` и новая [проверка](#object) со статусом `pending`. Повтор с тем же `Idempotency-Key` — `200 OK`, заголовок `Idempotent-Replayed: true` и **та же** проверка.

### Ошибки

| HTTP | `code` | Когда |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Ключ не передан, неверный или отозван |
| 402 | [`insufficient_checks`](#error-insufficient-checks) | Закончились проверки в пакете |
| 403 | [`account_disabled`](#error-account-disabled) | Аккаунт заблокирован |
| 403 | [`test_phone_only`](#error-test-phone-only) | Тестовый ключ и чужой номер |
| 422 | [`invalid_phone`](#error-invalid-phone) | Нет `phone` или номер не распознан |
| 422 | [`phone_not_supported`](#error-phone-not-supported) | Номера этой страны пока не обслуживаем — повтор не поможет |
| 422 | [`idempotency_key_reused`](#error-idempotency-key-reused) | Тот же `Idempotency-Key` уже использован с другим номером |
| 422 | [`invalid_request`](#error-invalid-request) | Тело не JSON, `metadata` не плоский объект или больше 20 полей |
| 429 | [`rate_limited`](#error-rate-limited) | Больше 120 запросов в минуту на ключ или больше 5 новых проверок одного номера за 10 минут |
| 503 | [`unavailable`](#error-unavailable) | Сейчас нет свободного номера; повторите через `Retry-After` секунд |

**Повторы.** Передавайте `Idempotency-Key`, привязанный к сеансу пользователя. Повтор с тем же ключом в течение
24 часов (двойной клик, обрыв сети) вернёт ту же проверку — ничего не спишется и не потратит лимит. Без ключа каждый
запрос создаёт новую проверку: повтор по номеру мы не распознаём, иначе чужой сеанс мог бы получить вашу проверку.

**Другой номер.** Пользователь не дозвонился — создайте проверку заново с новым ключом. Придёт другой `call_to`,
а прежняя проверка сразу станет `expired` (с вебхуком), звонок на её номер больше не засчитается.

```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(() => ({})); // прокси может вернуть 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(); // тело — JSON с call_to; разберите Jackson или 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"
}
```

## Получить статус

`GET /v1/verify/{id}`

Возвращает проверку с текущим статусом. Опрашивайте не чаще раза в 2 секунды, пока статус `pending`; при большом
потоке проверок используйте [вебхук](#webhooks).

### Параметры пути

| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| `id` | string | да | Идентификатор проверки из ответа `POST /v1/verify` |

### Ответ

`200 OK` и [объект проверки](#object).

### Ошибки

| HTTP | `code` | Когда |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | Ключ не передан, неверный или отозван |
| 403 | [`account_disabled`](#error-account-disabled) | Аккаунт заблокирован |
| 404 | [`not_found`](#error-not-found) | Проверки нет или она создана другим аккаунтом |
| 429 | [`rate_limited`](#error-rate-limited) | Больше 3000 запросов статуса в минуту на ключ |

```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(() => ({})); // прокси может вернуть 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"
}
```

## Ошибки

Ошибка всегда приходит в одном виде: `{"error": {"code", "message"}}`. Обрабатывайте её по `code` — он не меняется; `message` — пояснение для разработчика на английском.

| HTTP | `code` | Что делать |
|---|---|---|
| 401 | `unauthorized` | Проверьте ключ в «Настройки → API» |
| 402 | `insufficient_checks` | Купите пакет в кабинете |
| 403 | `account_disabled` | Аккаунт заблокирован — напишите в поддержку; ключ менять не нужно |
| 403 | `test_phone_only` | Используйте боевой ключ или номер своего аккаунта |
| 404 | `not_found` | Проверьте `id` |
| 405 | `invalid_request` | Метод не поддерживается по этому адресу (например, `DELETE /v1/verify/vrf_…`). Неизвестный адрес — `404` |
| 422 | `invalid_phone` | Передайте номер в международном формате |
| 422 | `phone_not_supported` | Страна номера пока не поддерживается. Не повторяйте запрос — предложите пользователю другой способ |
| 422 | `idempotency_key_reused` | Используйте новый `Idempotency-Key` для каждой попытки пользователя |
| 422 | `invalid_request` | Исправьте тело запроса, подробности в `message` |
| 429 | `rate_limited` | Повторите через `Retry-After` секунд |
| 500 | `internal_error` | Повторите; если не проходит — напишите в поддержку с `X-Request-Id` |
| 503 | `unavailable` | Повторите через `Retry-After` секунд |

**Лимиты** на ключ, независимые: `POST /v1/verify` — 120 в минуту и не больше 5 новых проверок одного номера
за 10 минут (повтор по `Idempotency-Key` не считается); `GET /v1/verify/{id}` — 3000 в минуту.

**Совместимость.** В рамках `v1` мы можем добавлять поля в ответы, новые типы событий и коды ошибок. Не
отклоняйте ответ из-за незнакомого поля, а незнакомые события и коды обрабатывайте как общий случай.

```json
{
  "error": {
    "code": "insufficient_checks",
    "message": "No verifications left. Buy a package in the dashboard."
  }
}
```


# Вебхуки

## Вебхуки

Укажите адрес у ключа в кабинете («Настройки → API → Изменить»). Когда проверка завершится, мы отправим на него
`POST` с событием — по одному на каждую проверку:

| Событие | Когда |
|---|---|
| `verification.verified` | Номер подтверждён |
| `verification.expired` | Звонка не было. Приходит через 35–95 секунд после `expires_at` или сразу, если по номеру создана новая проверка |

```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` — [проверка](#object) на момент события; `livemode: false` — событие тестового ключа.
- Ответьте `2xx` за 5 секунд. Иначе повторим через 1, 3 и 10 секунд, затем остановимся — статус всегда можно узнать
  через `GET /v1/verify/{id}`.
- Событие может прийти дважды: пропускайте повторы по `id`.
- Все отправки и ответы вашего сервера — в кабинете: «Настройки → API» → «Журнал доставки» у ключа.

## Подпись вебхука

Каждый вебхук подписан секретом ключа — `whsec_…` из «Настройки → API». Проверяйте подпись: так вы убедитесь,
что запрос отправила Telvera и его не изменили по дороге.

Подпись приходит в заголовке `X-Telvera-Signature`, например `t=1791473899,v1=5f2b9c…`:

- `t` — время отправки в unix-секундах;
- `v1` — HMAC-SHA256 от строки `{t}.{тело запроса}`, ключ — ваш секрет, результат — в hex.

Как проверить:

1. Возьмите тело запроса **как пришло, байт в байт** — до разбора JSON.
2. Посчитайте HMAC-SHA256 от `{t}.{тело}` своим секретом.
3. Сравните результат с `v1` функцией сравнения за постоянное время.
4. Отклоните запрос, если `t` отличается от текущего времени больше чем на 5 минут, — это защита от повторной
   отправки перехваченного запроса. Наши повторные доставки подписываются заново, со свежим `t`, и проходят.

**После перевыпуска секрета** мы ещё 24 часа подписываем вебхуки и прежним секретом. В заголовке будет два `v1`:
`t=…,v1=<подпись новым>,v1=<подпись прежним>`. Принимайте запрос, если совпал любой из них, — тогда вебхуки не
потеряются, пока вы обновляете секрет на своём сервере. Примеры кода это уже учитывают.

```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 — тело как пришло (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 — тело запроса как пришло, байт в байт
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 — тело запроса как пришло, байт в байт
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 — тело запроса как пришло, байт в байт
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 — тело запроса как пришло, байт в байт
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
```

# Запуск

## Песочница

Тестовый ключ `sk_test_…` (создаётся в «Настройки → API») работает как боевой, но:

- звонок настоящий, а проверять можно только номер вашего аккаунта;
- проверки не списываются из пакета;
- вебхуки приходят так же, как в боевом режиме.

В кабинете, в разделе [«Песочница»](https://app.telvera.io/ru/sandbox), весь путь можно пройти без кода и увидеть настоящие запросы и ответы.

## Поддержка

Создайте обращение в кабинете: [«Поддержка → Новое обращение»](https://app.telvera.io/ru/support/create). Укажите заголовок
`X-Request-Id` из ответа API — по нему мы найдём запрос, звонок и вебхук.
