HelloMeDocumentation

Send OTP codes with HelloMe

One call to send a code to your customers — WhatsApp first, SMS as fallback — and one call to check it. Prepaid, per code sent and per verification.

Last updated October 5, 2026

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.
First send
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"}'
Answer
{
  "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.

AnswerMeaning
401 server_key_invalidKey missing, revoked or unknown.
403 scope_required:writeThe 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.

FieldValueDefault
phoneThe 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.whatsapp
lengthDigits in the code, 4 to 8.6
ttlSeconds the code stays valid, 60 to 1,800 (30 min).300
referenceYour 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.—
ipYour end user's IP address, kept with the request for your own review. We do not use it.—
textYour 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.—
AnswerMeaningWhat to do
201The code left. “channel” says by which way, “delivery” where the WhatsApp delivery stands.Keep “id”; show the input screen.
400 phone_invalidThe number does not exist in this format.Have the number corrected.
400 country_not_allowed:FRThe number's country is not in your list of allowed countries.Widen the list in your settings, or refuse the sign-up.
402 insufficient_creditYour 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:reasonYour 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:otpThe 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_numberThis 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_reachedYour own hourly or daily ceiling is reached.Raise it in your settings, up to ours.
503 code_not_sentThe 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.

bash
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.

resultMeaningCharged
approvedThe right code. The request is closed: the same code will not be accepted again.yes
wrong_codeNot the right one. “attemptsLeft” says how many tries remain.yes
too_many_attemptsFive wrong codes: the request is dead. Send a new one.no
expiredThe time is up.no
already_usedThe code already served. Somebody is replaying a request.no
canceledYou had canceled it.no
failedThe 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.

deliveryMeaning
pendingWhatsApp took the message; we are waiting to see it delivered.
deliveredDelivered by WhatsApp. No SMS will leave.
smsThe code left by SMS — directly, or as fallback (“fellBack” is then true).
unknownNothing 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.

GET /otp/prices
{
  "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 chargedWhen
A code sentAs soon as the provider accepted it on one channel. Nothing when it refuses everywhere (503).
A verificationEach comparison of a live code: right or wrong. Nothing for a dead request (expired, already used, canceled).
Test numbersNever.
GET /otp/usage
{
  "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.

CeilingValueAnswer
Per number3 codes per ten minutes, 8 per day, from your workspace.429 too_many_for_number
Per hourYour setting (500 by default), at most 2,000.429 hourly_cap_reached
Per dayYour setting (5,000 by default), at most 20,000.429 daily_cap_reached
CountriesYour list of allowed countries; empty, every country passes.400 country_not_allowed
Share typed backFrom 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.

bash
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

CallWhat it does
GET /otp/requests/:idOne 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/cancelThe code stops being accepted and the fallback SMS, if not yet sent, will not be. What was charged stays charged.
GET /otp/usageToday, the month, the balance, the prices, your ceilings.
statusMeaning
sentLeft, waiting to be typed.
approvedTyped right.
expiredNobody typed it in time.
too_many_attemptsFive wrong codes.
failedRefused by the provider on every channel. Not charged.
canceledCanceled 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.

FieldValue
allowedCountriesTwo-letter ISO country codes (“TG”, “BJ”, “CI”…). Empty: all.
hourlyCapYour ceiling of codes per hour, 1 to 2,000.
dailyCapYour ceiling per day, 1 to 20,000.
testNumbersUp to ten numbers with their fixed code (4 to 8 digits).
textYour 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

Node.js — two routes of your API
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 });
});
PHP
$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";
Python
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”.

Send OTP codes with HelloMe · HelloMe