WhatsApp OTP verification (v1)
Otpera's OTP API sends a one-time code to a phone number on WhatsApp and checks the code your user types. Otpera generates the code, delivers it, enforces expiry and attempt limits, and stores it only as a hash. Your server never sees the code.
Use it for sign-up and login, phone-number verification, checkout confirmation, password resets and two-factor authentication.
How it works
1. Your server ── POST /verifications {to} ─────────▶ Otpera
2. Otpera ── WhatsApp: "Your code is 482913" ──▶ User
3. User ── types 482913 into your app ───────▶ Your server
4. Your server ── POST /verifications/:id/check ────▶ Otpera → { valid: true }| Step | Endpoint | Purpose |
|---|---|---|
| 1 | POST /verifications | Create a verification and send the code |
| 2 | POST /verifications/{id}/check | Check the code the user entered |
| – | POST /verifications/{id}/resend | Send a new code for the same verification |
| – | GET /verifications/{id} | Read the current status |
All endpoints need an API key with the verifications:write scope. Base URL: https://api.otpera.com/api/v1.
Prerequisites
- A workspace on otpera.com with a WhatsApp number linked (Dashboard → WhatsApp, scan the QR).
- An API key with
verifications:write(Dashboard → API keys). See Authentication.
1. Create a verification
POST /verifications
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | yes | Recipient phone in E.164 (+201012345678). Egyptian local numbers (01012345678) are also accepted. |
purpose | string | no | Your label, a-z 0-9 _ . -, up to 40 chars. Default verification. E.g. signup, login, checkout. |
locale | "ar" | "en" | no | Language of the WhatsApp message. Defaults to the workspace language. |
channel | "whatsapp" | no | Only WhatsApp today; accepted so your code can be explicit. |
Send an Idempotency-Key header so a retried request never sends a second code.
curl -X POST https://api.otpera.com/api/v1/verifications \
-H "Authorization: Bearer $OTPERA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2b9e-1c55-4c1e-8f0a-3f7b0e7f9a21" \
-d '{ "to": "+201012345678", "purpose": "signup", "locale": "ar" }'const res = await fetch('https://api.otpera.com/api/v1/verifications', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OTPERA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ to: '+201012345678', purpose: 'signup', locale: 'ar' }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { data } = await res.json();
// Save data.id with the user's session.$res = Http::withToken(config('services.otpera.key'))
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->post('https://api.otpera.com/api/v1/verifications', [
'to' => '+201012345678',
'purpose' => 'signup',
'locale' => 'ar',
]);
session(['otpera_verification_id' => $res->json('data.id')]);import os, uuid, requests
res = requests.post(
"https://api.otpera.com/api/v1/verifications",
headers={
"Authorization": f"Bearer {os.environ['OTPERA_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"to": "+201012345678", "purpose": "signup", "locale": "ar"},
timeout=15,
)
res.raise_for_status()
verification_id = res.json()["data"]["id"]Response 201 Created
{
"data": {
"id": "8b0c3f8e-2f7a-4f5e-9a51-0d6c1e6b2f10",
"recipient_e164": "+201012345678",
"purpose": "signup",
"channel": "whatsapp",
"status": "pending",
"attempts": 0,
"max_attempts": 5,
"send_count": 1,
"delivery_status": "sent",
"delivery_error": null,
"expires_at": "2026-11-05T10:10:00.000Z",
"verified_at": null,
"last_sent_at": "2026-11-05T10:00:00.000Z",
"created_at": "2026-11-05T10:00:00.000Z"
}
}The user receives a WhatsApp message like:
Your My Store verification code is 482913. It expires in 10 minutes. Don't share it with anyone.
A new verification for the same number and purpose cancels any earlier pending one, so only the latest code works.
2. Check the code
POST /verifications/{id}/check
| Field | Type | Required | Description |
|---|---|---|---|
code | string | yes | The 4–8 digit code the user typed |
curl -X POST https://api.otpera.com/api/v1/verifications/8b0c3f8e-2f7a-4f5e-9a51-0d6c1e6b2f10/check \
-H "Authorization: Bearer $OTPERA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "482913" }'Response 200 OK
{
"data": {
"id": "8b0c3f8e-2f7a-4f5e-9a51-0d6c1e6b2f10",
"valid": true,
"status": "verified",
"reason": null,
"attemptsRemaining": 4
}
}Always branch on valid. When it is false, reason tells you why:
reason | status | What to show the user |
|---|---|---|
incorrect_code | pending | "Wrong code" + attemptsRemaining |
too_many_attempts | failed | "Too many attempts. Request a new code." |
expired | expired | "Code expired. Request a new code." |
already_verified | verified | The code was already used; treat as a replay |
canceled | canceled | A newer code was requested; ask for the latest one |
A code verifies at most once, even if two checks arrive at the same time.
3. Resend a code
POST /verifications/{id}/resend. No body. Sends a fresh code for the same verification and extends its expiry.
- 30-second cooldown between sends (
429withretryAfterseconds). - Up to 5 sends per verification; after that, create a new verification.
- Only while the verification is
pending(409otherwise).
4. Get a verification
GET /verifications/{id} returns the same object as the create response. The code is never included.
Statuses
| Status | Meaning |
|---|---|
pending | Code sent; waiting for a check |
verified | The correct code was checked |
expired | 10 minutes passed without a correct check |
failed | Too many wrong attempts, or WhatsApp delivery failed |
canceled | Superseded by a newer verification for the same number/purpose |
Limits
| Limit | Value |
|---|---|
| Code lifetime | 10 minutes |
| Wrong attempts per verification | 5 |
| Sends per verification | 5 |
| Resend cooldown | 30 seconds |
| New verifications per phone number | 5 per 10 minutes |
| New verifications per workspace | 300 per hour |
Each delivered code uses 1 credit from your plan.
Errors
| HTTP | When |
|---|---|
401 | Missing or invalid API key |
403 | Key lacks verifications:write |
404 | Verification id not found in this workspace |
409 | No WhatsApp number linked (whatsapp_not_linked), or not pending |
422 | Invalid phone number, bad purpose, non-digit code, or bad Idempotency-Key |
429 | Rate limit or resend cooldown |
Full format: Errors & rate limits.
Best practices
- Call Otpera from your server only. Your frontend talks to your backend; your backend holds the API key.
- Bind the verification to the session. Store
data.idserver-side with the user's session; don't trust an id sent back from the browser alone. - Rate-limit your own endpoint that triggers
POST /verifications(per IP and per account) to stop SMS-pumping style abuse. - Show a resend timer of 30 seconds in your UI to match the cooldown.
- Normalize phone numbers to E.164 before sending; Otpera rejects invalid numbers with
422. - Use
purposeto separate flows (e.g.loginvscheckout) so one doesn't cancel the other.