# Telvera API

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

1. `POST /v1/verify` — send the user's number, get `call_to`, the number they should call.
2. The user calls `call_to` from their phone.
3. We send the `verification.verified` webhook (or you check the status with `GET /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:**

```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']; // show it to the user
```
```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(() => ({})); // 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
```
```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 raises; the error code is response.json()["error"]["code"]
verification = response.json()

print(verification["call_to"])  # show it to the user
```
```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()); // show it to the user
```
```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(); // the body is JSON with call_to; parse it with Jackson or 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) // show it to the user
```
```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"] # show it to the user
```

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

| Field | Type | Description |
|---|---|---|
| `id` | string | Identifier: `vrf_` followed by 32 hex characters |
| `status` | string | `pending` — waiting for the call, `verified` — number confirmed, `expired` — no call came |
| `phone` | string | The user's number, E.164 |
| `call_to` | string | The number the user should call, E.164 |
| `expires_at` | string | Until when we wait for the call: one minute after creation |
| `verified_at` | string \| null | When the call was counted |
| `metadata` | object \| null | Your data from the request, unchanged: same object, same value types |
| `created_at` | string | 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.

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

## Create a verification

`POST /v1/verify`

Returns 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

| Parameter | Type | Required | Description |
|---|---|---|---|
| `phone` | string | yes | The user's number in international format; spaces, brackets and dashes are fine. Missing or unrecognised — `invalid_phone` |
| `metadata` | object | no | 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`/`false` or `null`. Returned in responses and the webhook |
| `Idempotency-Key` | header | no | 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](#object) 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`](#error-unauthorized) | The key is missing, wrong or revoked |
| 402 | [`insufficient_checks`](#error-insufficient-checks) | No checks left in your package |
| 403 | [`account_disabled`](#error-account-disabled) | The account is blocked |
| 403 | [`test_phone_only`](#error-test-phone-only) | Test key with someone else's number |
| 422 | [`invalid_phone`](#error-invalid-phone) | `phone` is missing or not recognised |
| 422 | [`phone_not_supported`](#error-phone-not-supported) | Numbers of this country aren't supported yet — retrying won't help |
| 422 | [`idempotency_key_reused`](#error-idempotency-key-reused) | The same `Idempotency-Key` was already used with another number |
| 422 | [`invalid_request`](#error-invalid-request) | The body is not JSON, `metadata` is not a flat object or has more than 20 fields |
| 429 | [`rate_limited`](#error-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`](#error-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.

```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(() => ({})); // a proxy may return 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(); // the body is JSON with call_to; parse it with Jackson or 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 status

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

### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The verification ID from the `POST /v1/verify` response |

### Returns

`200 OK` and a [verification object](#object).

### Errors

| HTTP | `code` | When |
|---|---|---|
| 401 | [`unauthorized`](#error-unauthorized) | The key is missing, wrong or revoked |
| 403 | [`account_disabled`](#error-account-disabled) | The account is blocked |
| 404 | [`not_found`](#error-not-found) | No such verification, or it belongs to another account |
| 429 | [`rate_limited`](#error-rate-limited) | More than 3000 status requests per minute per key |

```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(() => ({})); // a proxy may return 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"
}
```

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

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

```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` is the [verification](#object) at the time of the event; `livemode: false` marks a test-key event.
- Reply `2xx` within 5 seconds. Otherwise we retry after 1, 3 and 10 seconds, then stop — you can always get the
  status with `GET /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.

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

1. Take the request body **exactly as received, byte for byte** — before parsing the JSON.
2. Compute HMAC-SHA256 of `{t}.{body}` with your secret.
3. Compare the result with `v1` using a constant-time comparison.
4. Reject the request if `t` differs from the current time by more than 5 minutes — this protects against replaying
   an intercepted request. Our delivery retries are signed again with a fresh `t`, 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
<?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 — 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);
  });
}
```
```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 — 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)));
}
```
```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 — 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;
}
```
```go
// 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
}
```
```ruby
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”](https://app.telvera.io/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”](https://app.telvera.io/support/create). Include the `X-Request-Id` header
from the API response — it lets us find the request, the call and the webhook.
