Get started
Overview
Telvera confirms a phone number with an incoming call instead of an SMS code. You show the user a number, they call it from their phone, we drop the call without answering (free for the user) and tell your server that the number is verified.
The API is two requests and a webhook:
POST /v1/verify— send the user's number, getcall_to, the number they should call.- The user calls
call_tofrom their phone. - We send the
verification.verifiedwebhook (or you check the status withGET /v1/verify/{id}).
Requests and responses are JSON, times are ISO 8601 in UTC, phone numbers in responses are E.164.
Quick start
1. Get a key in the dashboard: “Settings → API”. Send it in the Authorization: Bearer sk_live_… header.
Keep it on your server only.
2. Request a number to call:
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']; // show it to the user
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(() => ({})); // a proxy may return HTML
if (!response.ok) throw new Error(body.error?.code ?? `http_${response.status}`);
console.log(body.call_to); // show it to the user
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 raises; the error code is response.json()["error"]["code"]
verification = response.json()
print(verification["call_to"]) # show it to the user
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()); // show it to the user
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(); // the body is JSON with call_to; parse it with Jackson or 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) // show it to the user
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"] # show it to the user
{
"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. Show call_to to the user — as a tel: button on mobile, as a QR code on desktop — with a countdown to expires_at.
4. Get the result. Set a webhook URL on the key in the dashboard — after the call it receives
verification.verified; verify its signature. Without a webhook, poll GET /v1/verify/{id} every
2–3 seconds while the status is pending.
Reference
The verification object
Both methods return a verification, and the webhook carries it in data.
idstring- Identifier:
vrf_followed by 32 hex characters statusstringpending— waiting for the call,verified— number confirmed,expired— no call camephonestring- The user's number, E.164
call_tostring- The number the user should call, E.164
expires_atstring- Until when we wait for the call: one minute after creation
verified_atstring | null- When the call was counted
metadataobject | null- Your data from the request, unchanged: same object, same value types
created_atstring- When it was created
verified and expired are final. A call made in time may reach us with a delay, so for another 35 seconds after expires_at the verification stays pending and can still be confirmed — unless you create a new one for this number in the meantime.
{
"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"
}
Create a verification
/v1/verifyReturns the number the user should call — they have one minute. A check is taken from your package only when the status becomes verified.
Body parameters
phonestringrequired- The user's number in international format; spaces, brackets and dashes are fine. Missing or unrecognised —
invalid_phone metadataobject- A flat object of up to 20 fields. Key — a string up to 64 characters; value — a string up to 500 characters, a number,
true/falseornull. Returned in responses and the webhook Idempotency-Keyheader- Retry key — one per user session/attempt, e.g.
signup-<session id>. A repeat with the same key returns the same verification. 1–255 printable ASCII characters
Returns
201 Created and a new verification with status pending. A repeat with the same Idempotency-Key — 200 OK, header Idempotent-Replayed: true and the same verification.
Errors
| HTTP | code |
When |
|---|---|---|
| 401 | unauthorized |
The key is missing, wrong or revoked |
| 402 | insufficient_checks |
No checks left in your package |
| 403 | account_disabled |
The account is blocked |
| 403 | test_phone_only |
Test key with someone else's number |
| 422 | invalid_phone |
phone is missing or not recognised |
| 422 | phone_not_supported |
Numbers of this country aren't supported yet — retrying won't help |
| 422 | idempotency_key_reused |
The same Idempotency-Key was already used with another number |
| 422 | invalid_request |
The body is not JSON, metadata is not a flat object or has more than 20 fields |
| 429 | rate_limited |
More than 120 requests per minute per key, or more than 5 new verifications of the same number per 10 minutes |
| 503 | unavailable |
No free number right now; retry after Retry-After seconds |
Retries. Send an Idempotency-Key tied to the user's session. A repeat with the same key within 24 hours
(double click, network failure) returns the same verification — nothing is charged and the limit isn't used. Without
a key, every request creates a new verification: we don't detect repeats by phone number, otherwise another session
could get your verification.
Another number. If the user couldn't get through, create the verification again with a new key. You get a
different call_to, and the previous verification becomes expired right away (with a webhook); calls to its number
no longer count.
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(() => ({})); // a proxy may return 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(); // the body is JSON with call_to; parse it with Jackson or 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"
}
Get status
/v1/verify/{id}Returns the verification with its current status. Poll no more than once every 2 seconds while the status is
pending; for high volume, use the webhook.
Path parameters
idstringrequired- The verification ID from the
POST /v1/verifyresponse
Returns
200 OK and a verification object.
Errors
| HTTP | code |
When |
|---|---|---|
| 401 | unauthorized |
The key is missing, wrong or revoked |
| 403 | account_disabled |
The account is blocked |
| 404 | not_found |
No such verification, or it belongs to another account |
| 429 | rate_limited |
More than 3000 status requests per minute per key |
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(() => ({})); // a proxy may return 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"
}
Errors
An error always has the same shape: {"error": {"code", "message"}}. Handle it by code — it never changes; message is an explanation for the developer.
| HTTP | code |
What to do |
|---|---|---|
| 401 | unauthorized |
Check the key in “Settings → API” |
| 402 | insufficient_checks |
Buy a package in the dashboard |
| 403 | account_disabled |
The account is blocked — contact support; no need to change the key |
| 403 | test_phone_only |
Use a live key or your account's number |
| 404 | not_found |
Check the id |
| 405 | invalid_request |
Method not supported at this URL (e.g. DELETE /v1/verify/vrf_…). An unknown URL gives 404 |
| 422 | invalid_phone |
Send the number in international format |
| 422 | phone_not_supported |
Numbers of this country aren't supported yet. Don't retry — offer the user another way |
| 422 | idempotency_key_reused |
Use a new Idempotency-Key for each user attempt |
| 422 | invalid_request |
Fix the request body, details in message |
| 429 | rate_limited |
Retry after Retry-After seconds |
| 500 | internal_error |
Retry; if it keeps failing, contact support with X-Request-Id |
| 503 | unavailable |
Retry after Retry-After seconds |
Limits per key, independent: POST /v1/verify — 120 per minute and at most 5 new verifications of one number
per 10 minutes (a repeat with the same Idempotency-Key doesn't count); GET /v1/verify/{id} — 3000 per minute.
Compatibility. Within v1 we may add response fields, event types and error codes. Don't reject a response
because of an unknown field, and handle unknown events and codes as the generic case.
{
"error": {
"code": "insufficient_checks",
"message": "No verifications left. Buy a package in the dashboard."
}
}
Webhooks
Webhooks
Set the URL on the key in the dashboard (“Settings → API → Edit”). When a verification finishes, we send it a
POST with an event — one per verification:
| Event | When |
|---|---|
verification.verified |
The number is confirmed |
verification.expired |
No call was made. Arrives 35–95 seconds after expires_at, or right away if a new verification is created for the number |
datais the verification at the time of the event;livemode: falsemarks a test-key event.- Reply
2xxwithin 5 seconds. Otherwise we retry after 1, 3 and 10 seconds, then stop — you can always get the status withGET /v1/verify/{id}. - An event may arrive twice: skip duplicates by
id. - Every delivery and your server's response are in the dashboard: “Settings → API” → “Delivery log” on the key.
{
"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"
}
}
Webhook signature
Every webhook is signed with the key's secret — whsec_… from “Settings → API”. Verify the signature to make
sure the request was sent by Telvera and wasn't modified on the way.
The signature comes in the X-Telvera-Signature header, e.g. t=1791473899,v1=5f2b9c…:
t— the time of sending, in unix seconds;v1— HMAC-SHA256 of the string{t}.{request body}, keyed with your secret, hex-encoded.
How to verify:
- Take the request body exactly as received, byte for byte — before parsing the JSON.
- Compute HMAC-SHA256 of
{t}.{body}with your secret. - Compare the result with
v1using a constant-time comparison. - Reject the request if
tdiffers from the current time by more than 5 minutes — this protects against replaying an intercepted request. Our delivery retries are signed again with a fresht, so they pass.
After you rotate the secret, we keep signing webhooks with the previous secret too for 24 hours. The header then
carries two v1 values: t=…,v1=<signed with new>,v1=<signed with previous>. Accept the request if either one
matches — then no webhooks are lost while you update the secret on your server. The code examples already do this.
<?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 — the body as received (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 — the request body exactly as received
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 — the request body exactly as received
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 — the request body exactly as received
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 — the request body exactly as received
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
Go live
Sandbox
A test key sk_test_… (created in “Settings → API”) works like a live one, but:
- the call is real, and you can verify only your account's phone number;
- verifications are not taken from your package;
- webhooks arrive exactly as in live mode.
In the dashboard's “Sandbox” section you can walk through the whole flow without code and see the real requests and responses.
Support
Open a request in the dashboard: “Support → New request”. Include the X-Request-Id header
from the API response — it lets us find the request, the call and the webhook.