Echoo API
One API for every Echoo service. The first service is OTP: one-time verification codes delivered over WhatsApp.
One address, one kind of key, one error shape for every service. A key belongs to ONE profile (one website or app of the customer): the key alone tells which profile, so requests never name the profile. Live keys start with ek_live_, test keys with ek_test_ (a test key never sends a message; see Test mode).
Base URL: https://api.echoo.services
Quick start
- Make a key. In the Echoo dashboard, open your profile and make an API key. Start with a test key (
ek_test_…): it sends nothing and the code is always000000. Put it in your server's environment asECHOO_API_KEY. - Send a code when a person types their phone number. Keep the
idof the answer with their sign-up or login attempt.curl -X POST https://api.echoo.services/v1/otp/send \ -H "Authorization: Bearer $ECHOO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to":"+9647701234567","locale":"ar"}'const res = await fetch("https://api.echoo.services/v1/otp/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.ECHOO_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ to: "+9647701234567", locale: "ar" }), }); const data = await res.json();import os, requests res = requests.post( "https://api.echoo.services/v1/otp/send", headers={"Authorization": f"Bearer {os.environ['ECHOO_API_KEY']}"}, json={"to": "+9647701234567", "locale": "ar"}, timeout=15, ) data = res.json()<?php $ch = curl_init("https://api.echoo.services/v1/otp/send"); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("ECHOO_API_KEY"), "Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["to" => "+9647701234567", "locale" => "ar"]), ]); $data = json_decode(curl_exec($ch), true); - Check the code the person typed. Continue only when
validistrue.curl -X POST https://api.echoo.services/v1/otp/verify \ -H "Authorization: Bearer $ECHOO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"id":"otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G","code":"482913"}'const res = await fetch("https://api.echoo.services/v1/otp/verify", { method: "POST", headers: { Authorization: `Bearer ${process.env.ECHOO_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ id: "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", code: "482913" }), }); const data = await res.json();import os, requests res = requests.post( "https://api.echoo.services/v1/otp/verify", headers={"Authorization": f"Bearer {os.environ['ECHOO_API_KEY']}"}, json={"id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", "code": "482913"}, timeout=15, ) data = res.json()<?php $ch = curl_init("https://api.echoo.services/v1/otp/verify"); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("ECHOO_API_KEY"), "Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["id" => "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", "code" => "482913"]), ]); $data = json_decode(curl_exec($ch), true);
بالعربي: البدء بثلاث خطوات
- اعمل مفتاحاً من لوحة Echoo داخل البروفايل. ابدأ بالمفتاح التجريبي
ek_test_…: لا يرسل شيئاً والرمز دائماً000000. ضعه في متغيرات بيئة السيرفر باسمECHOO_API_KEY. - أرسل رمزاً بـ
POST /v1/otp/sendمع رقم الهاتف، واحفظidالجواب مع محاولة التسجيل أو الدخول. - تحقّق من الرمز بـ
POST /v1/otp/verifyمعidوالرمز الذي كتبه الشخص، وأكمل فقط إذا كانvalidيساويtrue.
المفتاح يُستعمل من السيرفر فقط، لا من المتصفح ولا من تطبيق الهاتف. كل مفتاح يخص بروفايلاً واحداً، فلا حاجة لذكر البروفايل بالطلب.
Keys
Authorization: Bearer ek_live_... (or ek_test_...). A key belongs to one profile and carries scopes (otp:send, otp:verify, otp:read). It is shown once when made; Echoo keeps only a hash of it.
Send it on every call as Authorization: Bearer ek_live_…. Call the API from your server only: a key in a web page or a mobile app can be copied. If a key leaks, revoke it in the dashboard and make a new one.
For AI agents
Building with an AI coding agent? Give it these instructions: the endpoints, the rules and the examples, in one text. No key is in it: it tells the agent to read ECHOO_API_KEY from the environment.
Also at /llms.txt, and the full machine-readable contract at /openapi.json.
Endpoints
POST/v1/otp/send
Send a one-time code. Makes a new code and queues it for delivery from one of the profile's WhatsApp numbers. The code is never returned and never stored: only a keyed hash is kept. Delivery is asynchronous: the answer says the request was accepted; its later status comes from GET /v1/otp/requests/{id} or a webhook.
Key scope: otp:send
Idempotency-Key header (optional): Any unique string (8 to 64 characters, for example a UUID). The same key with the same body within 24 hours returns the first answer and sends nothing new; the same key with a different body is refused (409 idempotency_conflict).
Body
| Field | Type | Description |
|---|---|---|
to required | string | The phone number with its country code: digits, optionally starting with +. Spaces and dashes are not accepted.. pattern ^\+?[1-9][0-9]{7,14}$ |
locale | string | The language of the message.. one of ar, en. default ar |
code_length | integer | default 6. 4 to 8 |
ttl_seconds | integer | How long the code works.. default 300. 60 to 900 |
reference | string | Your own id for this attempt (for example a sign-up id). Returned in the request and in webhooks. Do not put personal data in it.. 0 to 64 characters. pattern ^[A-Za-z0-9_.:-]*$ |
Example
curl -X POST https://api.echoo.services/v1/otp/send \
-H "Authorization: Bearer $ECHOO_API_KEY" \
-H "Idempotency-Key: 3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e1f0a3b" \
-H "Content-Type: application/json" \
-d '{"to":"9647701234567","locale":"ar","code_length":6,"ttl_seconds":300,"reference":"signup-58213"}'const res = await fetch("https://api.echoo.services/v1/otp/send", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ECHOO_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e1f0a3b" },
body: JSON.stringify({ to: "9647701234567", locale: "ar", code_length: 6, ttl_seconds: 300, reference: "signup-58213" }),
});
const data = await res.json();import os, requests
res = requests.post(
"https://api.echoo.services/v1/otp/send",
headers={"Authorization": f"Bearer {os.environ['ECHOO_API_KEY']}", "Idempotency-Key": "3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e1f0a3b"},
json={"to": "9647701234567", "locale": "ar", "code_length": 6, "ttl_seconds": 300, "reference": "signup-58213"},
timeout=15,
)
data = res.json()<?php
$ch = curl_init("https://api.echoo.services/v1/otp/send");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("ECHOO_API_KEY"), "Content-Type: application/json", "Idempotency-Key: 3f2b8c1e-9d4a-4f6b-8a2e-5c7d9e1f0a3b"],
CURLOPT_POSTFIELDS => json_encode(["to" => "9647701234567", "locale" => "ar", "code_length" => 6, "ttl_seconds" => 300, "reference" => "signup-58213"]),
]);
$data = json_decode(curl_exec($ch), true);Answer 202 · OtpRequest
{
"id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
"status": "queued",
"channel": "whatsapp",
"to_masked": "964*******67",
"created_at": "2026-11-14T10:00:00Z",
"expires_at": "2026-11-14T10:05:00Z",
"verified": false,
"reference": "signup-58213"
}Errors
- 400: The request is not valid (invalid_request, invalid_json, invalid_number, invalid_ttl).
- 401: invalid_api_key: the key is missing, wrong or revoked.
- 403: insufficient_scope or profile_suspended.
- 409: idempotency_conflict.
- 413: body_too_large.
- 422: country_not_allowed.
- 429: rate_limited, too_soon, too_many_for_number, profile_daily_limit, plan_limit_reached or capacity_exhausted.
- 500: server_error: something went wrong on our side.
- 503: no_number_available or unavailable.
POST/v1/otp/verify
Check a code. Checks the code the person typed. A code works once, at most 5 checks are allowed, and only before it expires. A wrong code is a normal answer (200 with valid=false and a reason), not an error. Only a key of the SAME profile that sent the code can check it.
Key scope: otp:verify
Body
| Field | Type | Description |
|---|---|---|
id required | string | pattern ^otp_req_[0-9A-Za-z]{20,40}$ |
code required | string | pattern ^[0-9]{4,8}$ |
Example
curl -X POST https://api.echoo.services/v1/otp/verify \
-H "Authorization: Bearer $ECHOO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G","code":"482913"}'const res = await fetch("https://api.echoo.services/v1/otp/verify", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ECHOO_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ id: "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", code: "482913" }),
});
const data = await res.json();import os, requests
res = requests.post(
"https://api.echoo.services/v1/otp/verify",
headers={"Authorization": f"Bearer {os.environ['ECHOO_API_KEY']}"},
json={"id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", "code": "482913"},
timeout=15,
)
data = res.json()<?php
$ch = curl_init("https://api.echoo.services/v1/otp/verify");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("ECHOO_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["id" => "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", "code" => "482913"]),
]);
$data = json_decode(curl_exec($ch), true);Answer 200 · VerifyResult
{
"id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
"valid": true
}{
"id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
"valid": false,
"reason": "invalid",
"attempts_left": 3
}Errors
- 400: The request is not valid (invalid_request, invalid_json, invalid_number, invalid_ttl).
- 401: invalid_api_key: the key is missing, wrong or revoked.
- 403: insufficient_scope or profile_suspended.
- 429: rate_limited, too_soon, too_many_for_number, profile_daily_limit, plan_limit_reached or capacity_exhausted.
- 500: server_error: something went wrong on our side.
- 503: no_number_available or unavailable.
GET/v1/otp/requests/{id}
Read one request. Where a request stands. Only keys of the profile that made it can read it; for any other key it does not exist (404).
Key scope: otp:read
{id}: the request id, like otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G.
Example
curl https://api.echoo.services/v1/otp/requests/otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G \
-H "Authorization: Bearer $ECHOO_API_KEY"const res = await fetch("https://api.echoo.services/v1/otp/requests/otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.ECHOO_API_KEY}` },
});
const data = await res.json();import os, requests
res = requests.get(
"https://api.echoo.services/v1/otp/requests/otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
headers={"Authorization": f"Bearer {os.environ['ECHOO_API_KEY']}"},
timeout=15,
)
data = res.json()<?php
$ch = curl_init("https://api.echoo.services/v1/otp/requests/otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("ECHOO_API_KEY")],
]);
$data = json_decode(curl_exec($ch), true);Answer 200 · OtpRequest
Errors
- 401: invalid_api_key: the key is missing, wrong or revoked.
- 403: insufficient_scope or profile_suspended.
- 404: not_found.
- 429: rate_limited, too_soon, too_many_for_number, profile_daily_limit, plan_limit_reached or capacity_exhausted.
- 500: server_error: something went wrong on our side.
Objects
OtpRequest
| Field | Type | Description |
|---|---|---|
id required | string | pattern ^otp_req_[0-9A-Za-z]{20,40}$ |
status required | string | queued: waiting for a number; sending: being sent; sent: handed to WhatsApp; failed: could not be delivered (see failure_reason); expired: its time ran out before it was sent.. one of queued, sending, sent, failed, expired |
failure_reason | string | Only when status is failed.. one of not_on_whatsapp, delivery_failed, timeout |
channel required | string | one of whatsapp |
to_masked required | string | The number with its middle hidden. |
created_at required | string (date-time) | |
expires_at required | string (date-time) | |
sent_at | string | null (date-time) | |
verified required | boolean | |
verified_at | string | null (date-time) | |
reference | string | null | |
test | boolean | true when made with a test key (nothing was sent). |
VerifyResult
| Field | Type | Description |
|---|---|---|
id required | string | pattern ^otp_req_[0-9A-Za-z]{20,40}$ |
valid required | boolean | |
reason | string | Only when valid is false.. one of invalid, expired, too_many_attempts, already_used, not_sent, not_found |
attempts_left | integer | Only when reason is invalid.. 0 to … |
WebhookEvent
| Field | Type | Description |
|---|---|---|
id required | string | Unique per event: use it to ignore a repeated delivery.. pattern ^evt_[0-9A-Za-z]{20,40}$ |
type required | string | one of otp.sent, otp.failed, otp.verified |
created_at required | string (date-time) | |
data required | OtpRequest |
Errors
Every error has the same shape. Write your program against code, never against the message. When retry_after is there (also as the Retry-After header), wait that many seconds before trying again.
{
"error": {
"code": "too_soon",
"message": "A code was just sent to this number. Wait before asking again.",
"retry_after": 30,
"request_id": "req_01JD8X2W3Y4Z5A6B7C8D9E0F1G"
}
}| Code | HTTP | Retry | Meaning |
|---|---|---|---|
invalid_request | 400 | No | The request is not valid. |
invalid_json | 400 | No | The body must be a JSON object. |
invalid_number | 400 | No | The phone number must be digits with the country code, for example +9647701234567. |
invalid_ttl | 400 | No | ttl_seconds must be between 60 and 900. |
invalid_api_key | 401 | No | The API key is missing, wrong or revoked. |
insufficient_scope | 403 | No | This key may not do this. Make a key with the needed scope. |
profile_suspended | 403 | No | This profile is paused. See your Echoo dashboard. |
not_found | 404 | No | No such request. |
method_not_allowed | 405 | No | This method is not allowed here. |
idempotency_conflict | 409 | No | This Idempotency-Key was used with a different request. |
body_too_large | 413 | No | The request body is too large. |
country_not_allowed | 422 | No | This profile may not send codes to numbers of that country. |
rate_limited | 429 | Yes | Too many requests with this key. Slow down. |
too_soon | 429 | Yes | A code was just sent to this number. Wait before asking again. |
too_many_for_number | 429 | Yes | Too many codes were sent to this number recently. |
profile_daily_limit | 429 | Yes | This profile reached its daily limit of codes. |
plan_limit_reached | 429 | No | Your plan's limit is reached. Upgrade your plan in the Echoo dashboard. |
capacity_exhausted | 429 | Yes | All numbers of this profile are at their limit right now. |
server_error | 500 | Yes | Something went wrong on our side. Try again. |
no_number_available | 503 | Yes | No WhatsApp number of this profile is connected right now. |
unavailable | 503 | Yes | The service is not reachable right now. Try again in a moment. |
Webhooks
Add an address in the Echoo dashboard and Echoo POSTs these events to it. Answer with any 2XX within 10 seconds. A repeated delivery carries the same event id: ignore ids you already handled.
| Event | When |
|---|---|
otp.sent | A code was handed to WhatsApp |
otp.failed | A code could not be delivered |
otp.verified | A code was checked successfully |
| Addresses | Added in the Echoo dashboard, inside the profile: a public https address (no user:password, no local or private network address), at most 5 per profile, each choosing which events it receives. |
|---|---|
| Signing secret | Each address gets its own signing secret, whsec_ followed by 40 letters and digits. It is shown ONCE, when the address is added; Echoo keeps it only encrypted. To change it, add a new address and delete the old one. |
| Delivery | POST with Content-Type application/json and the Echoo-Signature header. Any 2XX within 10 seconds counts as received; redirects are not followed. Every address of the profile that wants an event receives it with the same event id. |
| Retries | A delivery that is not received is tried again after 30 seconds, then 1, 2, 4, 8, 16, 32, 64, 128 and 256 minutes, then 6 hours: 12 tries in all, over about 14.5 hours. After 5 failures in a row the profile's admins see an alert in the dashboard. |
| Order and repeats | Events may arrive out of order or more than once: use the event id to ignore repeats and data.status for the latest state. |
| Test | The dashboard can send a signed test event (otp.sent with data.test = true) to an address at any time. |
The body is a WebhookEvent. Check that every delivery is from Echoo before you trust it: Echoo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of '<t>.<raw body>' with the endpoint's signing secret>. Refuse it if the signature does not match or t is older than 5 minutes. Keep the secret in your server's environment (for example ECHOO_WEBHOOK_SECRET), and read the raw body before parsing it.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string), before any JSON parsing.
export function isFromEchoo(rawBody, header, secret) {
const parts = Object.fromEntries(String(header).split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes: refuse
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}import hashlib, hmac, time
# raw_body: the request body exactly as received (bytes), before any JSON parsing.
def is_from_echoo(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0") or 0)
if not t or abs(time.time() - t) > 300: # older than 5 minutes: refuse
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))<?php
// $rawBody: file_get_contents("php://input"), before any JSON decoding.
function is_from_echoo(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(",", $header) as $p) {
[$k, $v] = array_pad(explode("=", $p, 2), 2, "");
$parts[$k] = $v;
}
$t = (int)($parts["t"] ?? 0);
if (!$t || abs(time() - $t) > 300) return false; // older than 5 minutes: refuse
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"] ?? "");
}Test mode
Keys that start with ek_test_.... Nothing is sent and nothing counts against limits or plans. Every send answers 202 and the request becomes 'sent' within a second. The code for any test request is 000000; any other code is 'invalid'. Webhooks are sent as for live requests, with data.test = true.
Limits
- Per key: 60 requests a minute.
- Per phone number: one code every 30 seconds and 3 an hour.
- Body: at most 4096 bytes.
Starting values; the service may lower them for safety, and they may change after measurement.
Good practice
- Protect your form. Put a human check and a per-visitor limit on the form that asks for a code, so nobody can use it to flood phone numbers (and your plan).
- Keep the id on your server. Store the request
idin the server session of the attempt; never trust an id sent back by the browser for another person. - Never handle the code yourself. Do not log it, store it or send it to the page: Echoo makes it, sends it and checks it.
- Retry safely. Send an
Idempotency-Keywith each send and reuse it when you retry, so a retry never sends a second code. - Respect the wait. On
429and503, waitretry_afterseconds; show the person a short message rather than retrying in a loop. - One key per place. Use separate keys for each server or app, with only the scopes it needs, so one can be revoked without stopping the others.
Versioning
v1 never changes in a breaking way: fields and error codes may be ADDED, never removed or renamed. A breaking change becomes /v2, announced in advance, and both run side by side for a period.