Начало работы
Обзор
Telvera подтверждает номер телефона входящим звонком — вместо SMS с кодом. Вы показываете пользователю номер, он звонит на него со своего телефона, мы сбрасываем вызов без ответа (для пользователя бесплатно) и сообщаем вашему серверу, что номер подтверждён.
API — это два запроса и вебхук:
POST /v1/verify— передаёте номер пользователя, получаетеcall_to— номер, на который ему позвонить.- Пользователь звонит на
call_toсо своего телефона. - Мы присылаем вебхук
verification.verified(или вы узнаёте статус черезGET /v1/verify/{id}).
Запросы и ответы — JSON, время — ISO 8601 в UTC, номера в ответах — E.164.
Быстрый старт
1. Возьмите ключ в кабинете: «Настройки → API». Ключ передаётся в заголовке Authorization: Bearer sk_live_….
Храните его только на сервере.
2. Запросите номер для звонка:
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']; // покажите пользователю
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); // покажите пользователю
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"]) # покажите пользователю
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()); // покажите пользователю
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
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) // покажите пользователю
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"] # покажите пользователю
{
"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; проверяйте его подпись. Без вебхука — опрашивайте GET /v1/verify/{id} раз
в 2–3 секунды, пока статус pending.
Справочник
Объект проверки
Проверку возвращают оба метода, и она же лежит в data вебхука.
idstring- Идентификатор:
vrf_и 32 hex-символа statusstringpending— ждём звонка,verified— номер подтверждён,expired— звонка не былоphonestring- Номер пользователя, E.164
call_tostring- Номер, на который пользователь должен позвонить, E.164
expires_atstring- До какого времени ждём звонок: минута от создания
verified_atstring | null- Когда засчитан звонок
metadataobject | null- Ваши данные из запроса, без изменений: тот же объект и те же типы значений
created_atstring- Когда создана
verified и expired — окончательные статусы. Звонок, сделанный вовремя, может дойти до нас с задержкой, поэтому ещё 35 секунд после expires_at проверка остаётся pending и может подтвердиться — если за это время не создать по этому номеру новую.
{
"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"
}
Создать проверку
/v1/verifyВыдаёт номер, на который пользователь должен позвонить, — на звонок даётся минута. Проверка списывается из пакета, только когда статус становится verified.
Параметры запроса
phonestringобязательный- Номер пользователя в международном формате; пробелы, скобки и дефисы допустимы. Нет поля или номер не распознан —
invalid_phone metadataobject- Плоский объект до 20 полей. Ключ — строка до 64 символов, значение — строка до 500 символов, число,
true/falseилиnull. Вернём в ответах и вебхуке Idempotency-Keyзаголовок- Ключ повтора — один на сеанс / попытку пользователя, например
signup-<id сеанса>. Повтор с тем же ключом вернёт ту же проверку. 1–255 печатных ASCII-символов
Ответ
201 Created и новая проверка со статусом pending. Повтор с тем же Idempotency-Key — 200 OK, заголовок Idempotent-Replayed: true и та же проверка.
Ошибки
| HTTP | code |
Когда |
|---|---|---|
| 401 | unauthorized |
Ключ не передан, неверный или отозван |
| 402 | insufficient_checks |
Закончились проверки в пакете |
| 403 | account_disabled |
Аккаунт заблокирован |
| 403 | test_phone_only |
Тестовый ключ и чужой номер |
| 422 | invalid_phone |
Нет phone или номер не распознан |
| 422 | phone_not_supported |
Номера этой страны пока не обслуживаем — повтор не поможет |
| 422 | idempotency_key_reused |
Тот же Idempotency-Key уже использован с другим номером |
| 422 | invalid_request |
Тело не JSON, metadata не плоский объект или больше 20 полей |
| 429 | rate_limited |
Больше 120 запросов в минуту на ключ или больше 5 новых проверок одного номера за 10 минут |
| 503 | unavailable |
Сейчас нет свободного номера; повторите через Retry-After секунд |
Повторы. Передавайте Idempotency-Key, привязанный к сеансу пользователя. Повтор с тем же ключом в течение
24 часов (двойной клик, обрыв сети) вернёт ту же проверку — ничего не спишется и не потратит лимит. Без ключа каждый
запрос создаёт новую проверку: повтор по номеру мы не распознаём, иначе чужой сеанс мог бы получить вашу проверку.
Другой номер. Пользователь не дозвонился — создайте проверку заново с новым ключом. Придёт другой call_to,
а прежняя проверка сразу станет expired (с вебхуком), звонок на её номер больше не засчитается.
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(() => ({})); // прокси может вернуть 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(); // тело — JSON с call_to; разберите Jackson или 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"
}
Получить статус
/v1/verify/{id}Возвращает проверку с текущим статусом. Опрашивайте не чаще раза в 2 секунды, пока статус pending; при большом
потоке проверок используйте вебхук.
Параметры пути
idstringобязательный- Идентификатор проверки из ответа
POST /v1/verify
Ответ
200 OK и объект проверки.
Ошибки
| HTTP | code |
Когда |
|---|---|---|
| 401 | unauthorized |
Ключ не передан, неверный или отозван |
| 403 | account_disabled |
Аккаунт заблокирован |
| 404 | not_found |
Проверки нет или она создана другим аккаунтом |
| 429 | rate_limited |
Больше 3000 запросов статуса в минуту на ключ |
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(() => ({})); // прокси может вернуть 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"
}
Ошибки
Ошибка всегда приходит в одном виде: {"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 мы можем добавлять поля в ответы, новые типы событий и коды ошибок. Не
отклоняйте ответ из-за незнакомого поля, а незнакомые события и коды обрабатывайте как общий случай.
{
"error": {
"code": "insufficient_checks",
"message": "No verifications left. Buy a package in the dashboard."
}
}
Вебхуки
Вебхуки
Укажите адрес у ключа в кабинете («Настройки → API → Изменить»). Когда проверка завершится, мы отправим на него
POST с событием — по одному на каждую проверку:
| Событие | Когда |
|---|---|
verification.verified |
Номер подтверждён |
verification.expired |
Звонка не было. Приходит через 35–95 секунд после expires_at или сразу, если по номеру создана новая проверка |
data— проверка на момент события;livemode: false— событие тестового ключа.- Ответьте
2xxза 5 секунд. Иначе повторим через 1, 3 и 10 секунд, затем остановимся — статус всегда можно узнать черезGET /v1/verify/{id}. - Событие может прийти дважды: пропускайте повторы по
id. - Все отправки и ответы вашего сервера — в кабинете: «Настройки → API» → «Журнал доставки» у ключа.
{
"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"
}
}
Подпись вебхука
Каждый вебхук подписан секретом ключа — whsec_… из «Настройки → API». Проверяйте подпись: так вы убедитесь,
что запрос отправила Telvera и его не изменили по дороге.
Подпись приходит в заголовке X-Telvera-Signature, например t=1791473899,v1=5f2b9c…:
t— время отправки в unix-секундах;v1— HMAC-SHA256 от строки{t}.{тело запроса}, ключ — ваш секрет, результат — в hex.
Как проверить:
- Возьмите тело запроса как пришло, байт в байт — до разбора JSON.
- Посчитайте HMAC-SHA256 от
{t}.{тело}своим секретом. - Сравните результат с
v1функцией сравнения за постоянное время. - Отклоните запрос, если
tотличается от текущего времени больше чем на 5 минут, — это защита от повторной отправки перехваченного запроса. Наши повторные доставки подписываются заново, со свежимt, и проходят.
После перевыпуска секрета мы ещё 24 часа подписываем вебхуки и прежним секретом. В заголовке будет два v1:
t=…,v1=<подпись новым>,v1=<подпись прежним>. Принимайте запрос, если совпал любой из них, — тогда вебхуки не
потеряются, пока вы обновляете секрет на своём сервере. Примеры кода это уже учитывают.
<?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 — тело как пришло (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 — тело запроса как пришло, байт в байт
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 — тело запроса как пришло, байт в байт
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 — тело запроса как пришло, байт в байт
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 — тело запроса как пришло, байт в байт
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») работает как боевой, но:
- звонок настоящий, а проверять можно только номер вашего аккаунта;
- проверки не списываются из пакета;
- вебхуки приходят так же, как в боевом режиме.
В кабинете, в разделе «Песочница», весь путь можно пройти без кода и увидеть настоящие запросы и ответы.
Поддержка
Создайте обращение в кабинете: «Поддержка → Новое обращение». Укажите заголовок
X-Request-Id из ответа API — по нему мы найдём запрос, звонок и вебхук.