Echoo API v1.0.0-draft.1

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

  1. 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 always 000000. Put it in your server's environment as ECHOO_API_KEY.
  2. Send a code when a person types their phone number. Keep the id of 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"}'
  3. Check the code the person typed. Continue only when valid is true.
    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"}'

بالعربي: البدء بثلاث خطوات

  1. اعمل مفتاحاً من لوحة Echoo داخل البروفايل. ابدأ بالمفتاح التجريبي ek_test_…: لا يرسل شيئاً والرمز دائماً 000000. ضعه في متغيرات بيئة السيرفر باسم ECHOO_API_KEY.
  2. أرسل رمزاً بـ POST /v1/otp/send مع رقم الهاتف، واحفظ id الجواب مع محاولة التسجيل أو الدخول.
  3. تحقّق من الرمز بـ 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

FieldTypeDescription
to requiredstringThe phone number with its country code: digits, optionally starting with +. Spaces and dashes are not accepted.. pattern ^\+?[1-9][0-9]{7,14}$
localestringThe language of the message.. one of ar, en. default ar
code_lengthintegerdefault 6. 4 to 8
ttl_secondsintegerHow long the code works.. default 300. 60 to 900
referencestringYour 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"}'

Answer 202 · OtpRequest

JSON
{
  "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

FieldTypeDescription
id requiredstringpattern ^otp_req_[0-9A-Za-z]{20,40}$
code requiredstringpattern ^[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"}'

Answer 200 · VerifyResult

JSON
{
  "id": "otp_req_01JD8X2W3Y4Z5A6B7C8D9E0F1G",
  "valid": true
}
JSON
{
  "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"

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

FieldTypeDescription
id requiredstringpattern ^otp_req_[0-9A-Za-z]{20,40}$
status requiredstringqueued: 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_reasonstringOnly when status is failed.. one of not_on_whatsapp, delivery_failed, timeout
channel requiredstringone of whatsapp
to_masked requiredstringThe number with its middle hidden.
created_at requiredstring (date-time)
expires_at requiredstring (date-time)
sent_atstring | null (date-time)
verified requiredboolean
verified_atstring | null (date-time)
referencestring | null
testbooleantrue when made with a test key (nothing was sent).

VerifyResult

FieldTypeDescription
id requiredstringpattern ^otp_req_[0-9A-Za-z]{20,40}$
valid requiredboolean
reasonstringOnly when valid is false.. one of invalid, expired, too_many_attempts, already_used, not_sent, not_found
attempts_leftintegerOnly when reason is invalid.. 0 to …

WebhookEvent

FieldTypeDescription
id requiredstringUnique per event: use it to ignore a repeated delivery.. pattern ^evt_[0-9A-Za-z]{20,40}$
type requiredstringone of otp.sent, otp.failed, otp.verified
created_at requiredstring (date-time)
data requiredOtpRequest

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.

JSON
{
  "error": {
    "code": "too_soon",
    "message": "A code was just sent to this number. Wait before asking again.",
    "retry_after": 30,
    "request_id": "req_01JD8X2W3Y4Z5A6B7C8D9E0F1G"
  }
}
CodeHTTPRetryMeaning
invalid_request400NoThe request is not valid.
invalid_json400NoThe body must be a JSON object.
invalid_number400NoThe phone number must be digits with the country code, for example +9647701234567.
invalid_ttl400Nottl_seconds must be between 60 and 900.
invalid_api_key401NoThe API key is missing, wrong or revoked.
insufficient_scope403NoThis key may not do this. Make a key with the needed scope.
profile_suspended403NoThis profile is paused. See your Echoo dashboard.
not_found404NoNo such request.
method_not_allowed405NoThis method is not allowed here.
idempotency_conflict409NoThis Idempotency-Key was used with a different request.
body_too_large413NoThe request body is too large.
country_not_allowed422NoThis profile may not send codes to numbers of that country.
rate_limited429YesToo many requests with this key. Slow down.
too_soon429YesA code was just sent to this number. Wait before asking again.
too_many_for_number429YesToo many codes were sent to this number recently.
profile_daily_limit429YesThis profile reached its daily limit of codes.
plan_limit_reached429NoYour plan's limit is reached. Upgrade your plan in the Echoo dashboard.
capacity_exhausted429YesAll numbers of this profile are at their limit right now.
server_error500YesSomething went wrong on our side. Try again.
no_number_available503YesNo WhatsApp number of this profile is connected right now.
unavailable503YesThe 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.

EventWhen
otp.sentA code was handed to WhatsApp
otp.failedA code could not be delivered
otp.verifiedA code was checked successfully
AddressesAdded 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 secretEach 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.
DeliveryPOST 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.
RetriesA 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 repeatsEvents may arrive out of order or more than once: use the event id to ignore repeats and data.status for the latest state.
TestThe 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));
}

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 id in 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-Key with each send and reuse it when you retry, so a retry never sends a second code.
  • Respect the wait. On 429 and 503, wait retry_after seconds; 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.