You protect a login, a sign-up or a payment with a six-digit code. Your server asks us to send it, then asks us whether what the person typed is right. That is all we do: no free-text message goes through here, which is what lets every company share one sender without any of them spoiling it for the others.
The channel is our call: today the code goes by SMS. When WhatsApp is switched on at our end, one channel goes first and, when it has not delivered within about twenty seconds or refused the message, the same code leaves again by the other. You do not see the difference and you pay the same price, once: a code that arrived.
Everything below is done with a server key of your HelloMe workspace, which must never leave your server, and with HelloMe credit, which you top up by mobile money.
1.API reference
The complete reference of every route, with a playground to try it with your key: hello-me.net/developers/api.
2.In five minutes
- Create your workspace in HelloMe: Support → new workspace. You own it.
- In its settings, create a server key with the “read” and “write” scopes. It is shown once: copy it into your server's configuration.
- Top up your HelloMe credit: send the amount by mobile money (Flooz, T-Money, MTN MoMo, Orange Money…) and give the transfer reference to our team from the Credit screen. The balance shows in FCFA and in dollars.
- Send your first code.
curl -X POST https://api.hello-me.net/api/v1/otp/send \
-H "x-hellome-key: hm_sk_your_server_key" \
-H "content-type: application/json" \
-d '{"phone": "+228 90 12 34 56"}'{
"data": {
"id": "66f1c0a2b3c4d5e6f7a8b9c0",
"phone": "+22890123456",
"country": "TG",
"status": "sent",
"channel": "whatsapp",
"channelAsked": "whatsapp",
"fellBack": false,
"delivery": "pending",
"length": 6,
"attempts": 0,
"attemptsLeft": 5,
"expiresAt": "2026-10-05T10:05:00.000Z",
"verifiedAt": null,
"reference": null,
"test": false,
"cost": { "usd": 0.03, "xof": 18 },
"createdAt": "2026-10-05T10:00:00.000Z"
}
}Not a company? From your HelloMe account, Settings → OTP codes (API) gives you a personal key and a mobile money top-up of your HelloMe credit: the same API, paid from your own credit.
Keep the “id” next to the session you are protecting: it is what you send back to verify. The code itself is never shown to you, and we do not keep it either — only its hash.
3.Authenticating
Every call carries your server key in the “x-hellome-key” header (or “Authorization: Bearer …”). The key belongs to one workspace: it sees only its requests, its credit, its settings. Sending, verifying, canceling and changing settings need the “write” scope; reading requests and usage works with “read”.
The key must never sit in a web page, a mobile app or a code repository. If it leaks, revoke it from the workspace settings and create another: it stops working at once, everywhere.
| Answer | Meaning |
|---|---|
| 401 server_key_invalid | Key missing, revoked or unknown. |
| 403 scope_required:write | The key only has the read scope. |
4.Sending a code
POST /otp/send. The body is a phone number and, if you want, a few choices.
| Field | Value | Default |
|---|---|---|
| phone | The number, international format. Spaces, dots and dashes are ignored; a missing “+” is added. | — |
| channel | “sms”: SMS only. “whatsapp”: whatever HelloMe does best — SMS today; when WhatsApp is on at our end, one channel then the other as fallback. Same price. | |
| length | Digits in the code, 4 to 8. | 6 |
| ttl | Seconds the code stays valid, 60 to 1,800 (30 min). | 300 |
| reference | Your own id for this request (64 characters at most). Sending it again returns the same answer without sending another code: a retry after a dropped connection is safe. | — |
| ip | Your end user's IP address, kept with the request for your own review. We do not use it. | — |
| text | Your own sentence around the code, with {code} exactly once where the digits go — “Your Zinsou code is {code}. Never share it.”. Sent as written by SMS; on WhatsApp our template serves. Bounded by HelloMe: a maximum length (code included) read from GET /otp/settings → limits.textMaxChars, and no link. Omitted: your default sentence from /otp/settings, else ours. | — |
| Answer | Meaning | What to do |
|---|---|---|
| 201 | The code left. “channel” says by which way, “delivery” where the WhatsApp delivery stands. | Keep “id”; show the input screen. |
| 400 phone_invalid | The number does not exist in this format. | Have the number corrected. |
| 400 country_not_allowed:FR | The number's country is not in your list of allowed countries. | Widen the list in your settings, or refuse the sign-up. |
| 402 insufficient_credit | Your credit does not cover a code. | Top up. Set up an alert on your side when the balance drops under a few days of use. |
| 403 otp_paused:reason | Your sends are paused — by you through HelloMe, or automatically when too few codes were typed back. | See the ceilings section; contact us to lift the pause sooner. |
| 403 feature_off:otp | The service is closed by HelloMe during an incident. | Try again later; the message often says why. |
| 400 text_code_placeholder_required / text_links_forbidden / text_too_long:<max> | Your sentence lacks {code} exactly once, carries a link, or is longer than allowed. | Fix the sentence; the allowed length is in GET /otp/settings. |
| 429 too_many_for_number | This number already got 3 codes in ten minutes, or 8 today. | Tell the person to wait and check their messages. |
| 429 hourly_cap_reached / daily_cap_reached | Your own hourly or daily ceiling is reached. | Raise it in your settings, up to ours. |
| 503 code_not_sent | The provider refused on every channel. Nothing is charged. | Offer to try again in a moment. |
A refused code is never charged. A code that left is, whether it traveled by WhatsApp, by SMS, or both.
5.Checking what the person typed
POST /otp/verify with the request id and the typed code. Without an id, give the number: the latest pending code for that number is checked.
curl -X POST https://api.hello-me.net/api/v1/otp/verify \
-H "x-hellome-key: hm_sk_your_server_key" \
-H "content-type: application/json" \
-d '{"id": "66f1c0a2b3c4d5e6f7a8b9c0", "code": "482913"}'The answer is the request, with a “result” field that says what to do. Only one result lets the person in.
| result | Meaning | Charged |
|---|---|---|
| approved | The right code. The request is closed: the same code will not be accepted again. | yes |
| wrong_code | Not the right one. “attemptsLeft” says how many tries remain. | yes |
| too_many_attempts | Five wrong codes: the request is dead. Send a new one. | no |
| expired | The time is up. | no |
| already_used | The code already served. Somebody is replaying a request. | no |
| canceled | You had canceled it. | no |
| failed | The code never left. | no |
Let the person in only on “approved”. A “200” means nothing more than “we answered”.
6.The WhatsApp → SMS fallback
When you ask for “whatsapp” (the default), the code leaves by WhatsApp and we watch its delivery. After about twenty seconds, if it is not marked delivered — number without WhatsApp, phone off, message refused — the same code leaves by SMS. The person can type whichever they read first: both messages carry the same code.
| delivery | Meaning |
|---|---|
| pending | WhatsApp took the message; we are waiting to see it delivered. |
| delivered | Delivered by WhatsApp. No SMS will leave. |
| sms | The code left by SMS — directly, or as fallback (“fellBack” is then true). |
| unknown | Nothing to follow: a test request, canceled or refused. |
- If WhatsApp refuses the message on the spot, the SMS leaves without waiting.
- If the fallback SMS is itself refused, the request stays as it is: the WhatsApp message may still arrive.
- The fallback is never charged on top: one code, one price.
- With “channel: sms”, none of this: the SMS leaves at once.
7.Prices and credit
Prices are in dollars and the same whatever the channel — WhatsApp or SMS, a code costs the same. The price of a code depends on the destination country: an SMS to one country does not cost what it costs to another. GET /otp/prices gives the price per country; a country not listed is charged the default price (defaultSendUsd) when sent, then adjusted within a minute, once the cost is known — “cost” on the request gives the final price. More generally, a code whose real cost exceeds what was charged (a dearer carrier than expected, a WhatsApp-then-SMS fallback) is raised to the country’s real price within a minute; never lowered. Sending needs a balance of at least two codes: the price, plus a reserve for that adjustment — otherwise 402 insufficient_credit. A verification has one price everywhere. The price charged is the one in force when the code is sent, and comes back in “cost” on the request.
{
"data": {
"defaultSendUsd": 0.03,
"verifyUsd": 0.005,
"usdRate": 600,
"rows": [
{ "country": "TG", "sendUsd": 0.52, "sendXof": 312 },
{ "country": "CI", "sendUsd": 0.30, "sendXof": 180 }
]
}
}Your HelloMe credit is kept in FCFA, the currency of your mobile money top-up; every line of your statement also carries its dollar price, and GET /otp/usage gives the balance in both. The rate applied is the one shown in “prices.usdRate”.
| What is charged | When |
|---|---|
| A code sent | As soon as the provider accepted it on one channel. Nothing when it refuses everywhere (503). |
| A verification | Each comparison of a live code: right or wrong. Nothing for a dead request (expired, already used, canceled). |
| Test numbers | Never. |
{
"data": {
"today": { "requested": 412, "sent": 410, "approved": 371, "failed": 2,
"byWhatsApp": 352, "bySms": 58, "fellBack": 41,
"verifications": 455, "spentXof": 8745, "spentUsd": 14.575 },
"month": { "...": "the same counters since the 1st" },
"balance": { "xof": 112500, "usd": 187.5 },
"prices": { "sendUsd": 0.03, "verifyUsd": 0.005, "sendXof": 18, "verifyXof": 3, "usdRate": 600 },
"limits": { "perPhonePer10Min": 3, "perPhonePerDay": 8, "hourlyCap": 500, "dailyCap": 5000 },
"paused": null
}
}When the balance no longer covers a code, the send answers 402 and nothing leaves. Two simultaneous sends may take the balance very slightly below zero; the next one is refused. Watch “balance” and warn yourself a few days ahead.
8.Ceilings and fraud protection
The danger with OTP codes is traffic that is not people: a script requesting codes toward numbers that pay the sender on every SMS received. It empties your credit in a night. These ceilings are there so that it stops by itself.
| Ceiling | Value | Answer |
|---|---|---|
| Per number | 3 codes per ten minutes, 8 per day, from your workspace. | 429 too_many_for_number |
| Per hour | Your setting (500 by default), at most 2,000. | 429 hourly_cap_reached |
| Per day | Your setting (5,000 by default), at most 20,000. | 429 daily_cap_reached |
| Countries | Your list of allowed countries; empty, every country passes. | 400 country_not_allowed |
| Share typed back | From 40 codes in an hour, if fewer than 5 % are typed right, your sends are paused for an hour. | 403 otp_paused:low_verification |
- Restrict countries to those where you have customers: it is the measure that stops the most fraud for the least effort.
- Put your own limit in front of your send endpoint: per session, per IP address, and a delay before “send again”.
- Do not reveal whether a number has an account with you: answer the same for an unknown number.
- An automatic pause lifts itself after an hour; our team can lift it sooner if your traffic has an explanation.
9.Test numbers
In your settings, up to ten numbers can have a fixed code. A send toward one of them creates a normal request but sends nothing and costs nothing; verification accepts the fixed code. It is what you need for automated tests, and to let an app-store reviewer log in without a phone of their own.
curl -X PATCH https://api.hello-me.net/api/v1/otp/settings \
-H "x-hellome-key: hm_sk_your_server_key" \
-H "content-type: application/json" \
-d '{"testNumbers": [{"phone": "+22890000099", "code": "424242"}]}'Test requests appear in the list with “test: true” and count neither in usage nor in ceilings.
10.Following requests
| Call | What it does |
|---|---|
| GET /otp/requests/:id | One request: its status, channel, delivery, cost. |
| GET /otp/requests?phone=&status=&from=&to=&limit= | Your requests, newest first, 200 at most. Filter by number, status or period. |
| POST /otp/requests/:id/cancel | The code stops being accepted and the fallback SMS, if not yet sent, will not be. What was charged stays charged. |
| GET /otp/usage | Today, the month, the balance, the prices, your ceilings. |
| status | Meaning |
|---|---|
| sent | Left, waiting to be typed. |
| approved | Typed right. |
| expired | Nobody typed it in time. |
| too_many_attempts | Five wrong codes. |
| failed | Refused by the provider on every channel. Not charged. |
| canceled | Canceled by you. |
Requests are kept ninety days, long enough for any dispute, then erased. Your credit statement stays.
11.Your settings
GET and PATCH /otp/settings. Only the fields you send change.
| Field | Value |
|---|---|
| allowedCountries | Two-letter ISO country codes (“TG”, “BJ”, “CI”…). Empty: all. |
| hourlyCap | Your ceiling of codes per hour, 1 to 2,000. |
| dailyCap | Your ceiling per day, 1 to 20,000. |
| testNumbers | Up to ten numbers with their fixed code (4 to 8 digits). |
| text | Your default sentence around the code, with {code}. Same rules as the text field on send. Empty or null: ours. |
The answer also recalls our own limits (“limits”, including textMaxChars and customTextAllowed for your sentence) and any pause in progress (“paused”).
12.Server side: a complete example
const HELLOME = "https://api.hello-me.net/api/v1";
const headers = {
"x-hellome-key": process.env.HELLOME_SERVER_KEY,
"content-type": "application/json",
};
// 1. The user gives their number.
app.post("/login/start", async (req, res) => {
const answer = await fetch(HELLOME + "/otp/send", {
method: "POST",
headers,
body: JSON.stringify({
phone: req.body.phone,
reference: req.session.id, // a retry does not send another code
ip: req.ip,
}),
});
const body = await answer.json();
if (!answer.ok) {
// 402: warn your team; 429: ask to wait; 400: number to correct
return res.status(answer.status).json({ error: body.message });
}
req.session.otpId = body.data.id; // never the code: you do not have it
res.json({ ok: true, expiresAt: body.data.expiresAt });
});
// 2. The user types the code.
app.post("/login/verify", async (req, res) => {
const answer = await fetch(HELLOME + "/otp/verify", {
method: "POST",
headers,
body: JSON.stringify({ id: req.session.otpId, code: req.body.code }),
});
const { data } = await answer.json();
if (data?.result !== "approved") {
return res.status(401).json({ error: data?.result ?? "verify_failed", attemptsLeft: data?.attemptsLeft });
}
req.session.user = await findOrCreateUser(data.phone);
res.json({ ok: true });
});$hellome = "https://api.hello-me.net/api/v1";
$headers = ["x-hellome-key: " . getenv("HELLOME_SERVER_KEY"), "content-type: application/json"];
function hellome(string $path, array $body): array {
global $hellome, $headers;
$ch = curl_init($hellome . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$status, json_decode($raw, true)];
}
[$status, $sent] = hellome("/otp/send", ["phone" => $_POST["phone"], "reference" => session_id()]);
if ($status >= 300) { /* 402, 429, 400: see the answers table */ }
$_SESSION["otp_id"] = $sent["data"]["id"];
// later
[$status, $checked] = hellome("/otp/verify", ["id" => $_SESSION["otp_id"], "code" => $_POST["code"]]);
$loggedIn = ($checked["data"]["result"] ?? "") === "approved";import os, requests
HELLOME = "https://api.hello-me.net/api/v1"
HEADERS = {"x-hellome-key": os.environ["HELLOME_SERVER_KEY"]}
def send_code(phone: str, reference: str) -> dict:
answer = requests.post(f"{HELLOME}/otp/send", json={"phone": phone, "reference": reference}, headers=HEADERS, timeout=15)
if answer.status_code == 402:
raise RuntimeError("HelloMe credit exhausted: top up.")
answer.raise_for_status()
return answer.json()["data"]
def check_code(request_id: str, code: str) -> bool:
answer = requests.post(f"{HELLOME}/otp/verify", json={"id": request_id, "code": code}, headers=HEADERS, timeout=15)
answer.raise_for_status()
return answer.json()["data"]["result"] == "approved"13.What keeps you out of trouble
- Use “reference” with the session id: a button pressed twice or a dropped connection does not send a second code — and does not cost you one.
- Verify by “id” rather than by number when you can: it is unambiguous when the person asked for two codes.
- Never write the code to your logs, nor what the person typed.
- Show “expiresAt” to the person, and offer “send again” only after thirty seconds: the SMS fallback needs those seconds.
- Treat 402 as an operations alert, not a user error: warn your team, show “try again in a moment”.
- Our per-number limits are yours too: a screen that lets someone ask for ten codes in a minute will be refused from the fourth.
The full description of every call, with its schemas, is in the interactive API documentation, section “OTP for Companies”.