Secret keys stay on your server
Never put sk_live_… or sk_test_… in browser JavaScript, a mobile app, or a public repository. The browser should only talk to your backend. Your backend calls Shernova with the secret key.
End-to-end flow
The user never installs an app. Your server creates the session, your page shows the gateway number, and the call itself proves ownership. A number is only trusted when the session status returned by the API (or a signed webhook) is verified or completed.
1. Create a session
Send the user's phone in E.164 format and a stable client_request_id so retries are idempotent. Keep the secret key in an environment variable.
const API = 'https://api.shernova.com';
export async function createVerification(phoneNumber, requestId) {
const res = await fetch(API + '/v1/verifications', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.SHERNOVA_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone_number: phoneNumber,
client_request_id: requestId,
}),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.message), { data });
return data;
}import os, requests
def create_verification(phone_number: str, request_id: str) -> dict:
response = requests.post(
"https://api.shernova.com/v1/verifications",
headers={"Authorization": f"Bearer {os.environ['SHERNOVA_SECRET_KEY']}"},
json={"phone_number": phone_number, "client_request_id": request_id},
timeout=20,
)
response.raise_for_status()
return response.json()<?php
function create_verification(string $phone, string $requestId): array {
$ch = curl_init('https://api.shernova.com/v1/verifications');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SHERNOVA_SECRET_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'phone_number' => $phone,
'client_request_id' => $requestId,
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException((string) $body);
return json_decode((string) $body, true);
}2. Handle queued and waiting correctly
gateway_phone_number is null while the session is queued because no verification line is free yet. Never show a placeholder number or let the user call before status === "waiting". The response for a waiting session looks like this:
{
"session_id": "8f14e45f-ea3c-4f3f-a1c2-4d7c8b6a9f01",
"status": "waiting",
"phone_number": "+201012345678",
"gateway_phone_number": "+201001234567",
"qr_code_data": "tel:+201001234567",
"environment": "live",
"expires_at": "2026-10-21T12:05:00.000Z"
}- Poll every 2–4 seconds while status is
queuedorwaiting. - When status becomes
waiting, show the number as atel:link and ask the user to ring once and hang up. - If the session expires, create a new one. Expired sessions cannot be revived.
- Let the user cancel a queued/waiting session when they change their mind so the line is released.
qr_code_data is a tel: URI for the same gateway number. Render it as a QR code next to the number so desktop users can scan it with the phone that will place the call — no typing required. It is null while queued and after the session leaves waiting.
3. Poll status and cancel
export async function getVerification(sessionId) {
const res = await fetch(API + '/v1/verifications/' + encodeURIComponent(sessionId), {
headers: { Authorization: 'Bearer ' + process.env.SHERNOVA_SECRET_KEY },
});
return res.json();
}
export async function cancelVerification(sessionId) {
const res = await fetch(API + '/v1/verifications/' + encodeURIComponent(sessionId), {
method: 'DELETE',
headers: { Authorization: 'Bearer ' + process.env.SHERNOVA_SECRET_KEY },
});
return res.json();
}curl "https://api.shernova.com/v1/verifications/SESSION_UUID" \
-H "Authorization: Bearer sk_live_YOUR_SECRET_KEY"
curl -X DELETE "https://api.shernova.com/v1/verifications/SESSION_UUID" \
-H "Authorization: Bearer sk_live_YOUR_SECRET_KEY"4. Treat verified as success
In the current implementation verified already means the caller's number matched the session and is safe to accept. completed means the session was also delivered to a webhook. Do not wait for completed when you are polling: accept verified immediately and make your database update idempotent.
| Status | Meaning | What your website should do |
|---|---|---|
| queued | No line is free yet; gateway number is null | Keep polling, show a waiting message |
| waiting | A line is assigned; show the gateway number | Ask for one missed call |
| verified | The call matched; ownership is proven | Accept the verification, store the result |
| completed | Webhook delivery completed | Treat as verified too (idempotent) |
| expired | The call window closed | Offer a new attempt |
| failed / rejected | The call could not verify the number | Show retry |
| cancelled | You or the user cancelled | Offer a new attempt |
5. Prefer webhooks in production
Webhooks remove polling from your critical path. Verify the X-Shernova-Signature HMAC against the raw request body before parsing JSON, then handle the event idempotently. See Webhooks for the event schema and retries.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.post('/webhooks/shernova', express.raw({ type: 'application/json' }), (req, res) => {
const received = String(req.headers['x-shernova-signature'] || '').replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', process.env.SHERNOVA_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString());
if (event.status === 'verified' || event.status === 'completed') {
// Mark the phone/user verified in your own database.
}
res.sendStatus(200);
});import hmac, hashlib, os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/shernova")
def shernova_webhook():
raw = request.get_data()
received = request.headers.get("X-Shernova-Signature", "").removeprefix("sha256=")
expected = hmac.new(
os.environ["SHERNOVA_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, received):
return "invalid signature", 401
event = request.get_json(force=True)
if event.get("status") in ("verified", "completed"):
pass # mark verified in your database
return "", 2006. Errors your backend must handle
| HTTP | Error code | Meaning |
|---|---|---|
| 400 | invalid_phone | Not a valid E.164 phone number |
| 401 | unauthorized | Missing or invalid secret key |
| 403 | phone_verification_required | Shernova account owner must verify their own phone first |
| 409 | phone_verification_in_progress | Another pending session exists for this phone |
| 429 | verification_rate_limited | Per-phone/IP/device limits; respect retry_after_seconds |
| 503 | maintenance_mode | Verification is paused; show the bilingual message |
Retries with the same client_request_id return the same session instead of creating a duplicate. Use a UUID from your own order/signup record.
7. Security and capacity checklist
- Keep
sk_*on the server only; proxy create/status/cancel through your backend. - Rate-limit your own start endpoint per authenticated user, not just per IP.
- Accept only
verifiedorcompleted; never trust a browser flag. - Validate and normalize phone numbers before sending them.
- Use HTTPS and verify webhook signatures on the raw body.
- Do not create a second session for a phone that already has an active one; reuse it.
- Clean up abandoned sessions by cancelling or letting them expire.
8. Runnable examples
The repository contains dependency-free Node.js, PHP, and Python examples in examples/web-integration/. Each server exposes start, status, cancel, and webhook routes and reads the secret key from the environment. Start with the README in that folder.
