How do I send OTP texts from my own number?

Call POST /api/v1/verify with the person's number, and your phone texts them a code from your own number. When they type it in, POST /api/v1/verify/{id}/check says whether it's right. Sparetext makes the code, sends it ahead of other waiting texts, drops it unsent if it can't go out before it expires, and ends it after five wrong tries.

What you need

A Sparetext account (Free is 1 phone and 300 texts a month, and each code is one text), an Android phone paired with it, and an API key from the API keys page. An iPhone works through Sparetext for Mac. The SMS goes out on your own carrier plan; Sparetext charges no fee per text.

Two calls: send a code, check it

The first call texts the code and answers with the verification's id. Keep the id with the sign-in attempt and send what the person types to the second call. The check answers valid: true once; after that the code is spent.

bash
# 1. Text a code to the number the person gave you
curl -X POST https://sparetext.dev/api/v1/verify \
  -H "x-api-key: $SPARETEXT_API_KEY" -H "content-type: application/json" \
  -d '{"to": "+12025550123"}'
# → 201 {"verification": {"id": "vrf_…", "status": "pending", "messageId": "msg_…", …}}

# 2. Check the code they typed
curl -X POST https://sparetext.dev/api/v1/verify/vrf_…/check \
  -H "x-api-key: $SPARETEXT_API_KEY" -H "content-type: application/json" \
  -d '{"code": "481920"}'
# → 200 {"valid": true, "verification": {"status": "approved", …}}

The same in Python

With the requests library; check_code returns False once five wrong codes have ended the verification.

Python
import os

import requests

API = "https://sparetext.dev/api/v1"
HEADERS = {"x-api-key": os.environ["SPARETEXT_API_KEY"]}


def send_code(phone):
    r = requests.post(f"{API}/verify", headers=HEADERS, json={"to": phone, "ttl": 300})
    r.raise_for_status()  # 429: this number already got five codes this hour
    return r.json()["verification"]["id"]


def check_code(verification_id, typed):
    url = f"{API}/verify/{verification_id}/check"
    r = requests.post(url, headers=HEADERS, json={"code": typed})
    if r.status_code == 429:
        return False  # five wrong codes: the verification has ended
    r.raise_for_status()
    return r.json()["valid"]

The same in Node.js

With the fetch built into Node 18 and later.

Node.js
const API = "https://sparetext.dev/api/v1";
const headers = {
  "x-api-key": process.env.SPARETEXT_API_KEY,
  "content-type": "application/json",
};
const post = (path, body) =>
  fetch(API + path, { method: "POST", headers, body: JSON.stringify(body) });

export async function sendCode(phone) {
  const res = await post("/verify", { to: phone, ttl: 300 });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error.message); // 429: five codes to this number this hour
  return body.verification.id;
}

export async function checkCode(verificationId, typed) {
  const res = await post(`/verify/${verificationId}/check`, { code: typed });
  if (res.status === 429) return false; // five wrong codes: the verification has ended
  const body = await res.json();
  if (!res.ok) throw new Error(body.error.message);
  return body.valid;
}

What Verify does for you

The code is 6 random digits, or 4 to 10 with length. It works for 10 minutes, or 1 to 30 with ttl in seconds (60 to 1800). Your own text takes up to 300 characters, with {{code}} where the code goes and {{minutes}} for how long it lasts. Without one, it says: Your code is 481920. It expires in 10 minutes. Don't share it.

The code goes as a high-priority text, ahead of any texts already waiting on the phone, such as a big send. With a ttl of 15 minutes or less it also skips the phone's sending hours and caps, which still count it. If it hasn't gone out by the time it expires, it's dropped unsent with the error expired, so it never arrives late.

The verification keeps only a hash of the code. GET /api/v1/verify/{id} shows its status: pending, approved, failed or expired. It's kept for a day after it expires, then deleted.

Tries and limits

Five wrong codes end a verification with 429 too_many_tries: start a new one. A number gets five codes an hour at most, whoever asks; a sixth is refused with 429 rate_limited and a Retry-After header, so nobody can flood a number through your sign-in form. A number that replied STOP gets no codes (409 opted_out) until it replies START.

Verify sends with your API key, so the key's limits apply: a key limited to some numbers can verify only those, and a daily cap counts each code.

After the check

The text that carried the code sits in your message history like any other. Once the check succeeds, remove its words with POST /api/v1/messages/{messageId}/redact, using the verification's messageId; redacting needs the dashboard or a key without limits. Or set Keep texts' words for in Settings › Your data, and every text's words go after 30, 90 or 365 days.

Doing it yourself with POST /messages

To make and check codes yourself, send each one as an ordinary text with three settings. Setting priority to high puts it ahead of waiting texts. A ttl as long as the code lasts drops a code that can't go out in time instead of delivering it late, and at 900 seconds or less it also skips sending hours and caps. An Idempotency-Key header makes a retry after a timeout get the first answer back instead of texting a second code; a key is kept 24 hours, and the same key with a different body is refused with 422.

The rest is yours: make the code with a secure random generator, store only a hash and its expiry, limit tries and codes per number, and redact the text once it has gone out. A text still waiting can't be redacted (409); cancel it instead.

bash
# A code you made yourself (store only its hash), ahead of waiting texts, dropped if late
curl -X POST https://sparetext.dev/api/v1/messages \
  -H "x-api-key: $SPARETEXT_API_KEY" -H "content-type: application/json" \
  -H "Idempotency-Key: login-8812-code" \
  -d '{"to": "+12025550123", "priority": "high", "ttl": 300,
       "text": "Your code is 481920. It expires in 5 minutes."}'

# Once it has gone out, remove its words from your history
curl -X POST https://sparetext.dev/api/v1/messages/msg_20261031_x7k2p9qa/redact \
  -H "x-api-key: $SPARETEXT_API_KEY"

Keep it to one SMS

One SMS holds 160 characters in GSM-7, or 153 per segment when a text is longer, and carriers bill each segment. One character outside GSM-7, such as an emoji, a curly apostrophe or Amharic, makes the whole text UCS-2: 70 characters, or 67 per segment. "Your Example sign-in code is 481920. It expires in 10 minutes. Don't share it." is 78 characters and one SMS; with a curly ’ in Don’t, it's two. Turn on Swap look-alike characters in Settings › Sending, and curly quotes and dashes become plain ones when that keeps a text in GSM-7.

Browsers can fill the code in for the person when the text's last line is your site's domain and the code: @example.test #481920. Chrome on Android reads it with the WebOTP API, and Safari on iPhone and Mac offers it in a field with autocomplete="one-time-code". Put that line in your own text with {{code}}, as below.

JSON
{
  "to": "+12025550123",
  "ttl": 300,
  "text": "{{code}} is your Example code. It expires in {{minutes}} minutes.\n\n@example.test #{{code}}"
}

From an iPhone

A sign-in code has to go out with nobody at the phone, and on an iPhone that's Sparetext for Mac: it sends through Messages from the iPhone's own number, by Text Message Forwarding, while the Mac is awake. Without a Mac, the iPhone sends through a Shortcut while the Sparetext app is open, or the Send while locked automation, and tap to send waits for you; keep those for codes you can watch go. An iPhone or Mac reports sent or failed; only Android gets the carrier's delivered report.

When to use a registered provider instead

Codes your software sends are application-to-person (A2P) traffic, even from a personal SIM. Sparetext has no registration step, but that isn't the same as no rules: carriers watch personal numbers for A2P traffic and can filter or block a SIM that sends a lot of it, and in the US that traffic is meant to go over a registered 10DLC, toll-free or short-code number. Android also lets an app send 30 SMS parts a minute, and Sparetext paces under that.

So if you send codes to thousands of people a day, need a branded sender ID, or can't afford a filtered code at sign-up, use a registered SMS provider, or keep one as a fallback.

Where it fits

A young app with its first few hundred users, before a registered sender is worth its setup. Internal logins: staff tools, admin panels and two-step checks for people who know your number. Countries where registering a sender ID is slow or costly, such as Ethiopia, where a local SIM reaches local numbers at your own plan's price.

Questions people ask

Is there a free OTP SMS API?
Sparetext's Free plan is 1 phone and 300 texts a month, and each code is one text. There's no fee per text: the SMS goes out on your own carrier plan.
How long is a code valid?
10 minutes unless you set ttl, from 60 to 1800 seconds. A code that hasn't gone out by then is dropped instead of arriving late, and checking it answers expired.
How many tries does a person get?
Five. The fifth wrong code ends the verification with 429 too_many_tries, and the right code works once.
Can someone flood a number with codes?
No. A number gets five codes an hour at most from your account, whoever asks; the sixth is refused with 429 rate_limited and a Retry-After header.
Will a code wait behind a big send?
No. Codes go as high-priority texts, ahead of what's already waiting on the phone, and with a ttl of 15 minutes or less they also skip the phone's sending hours and caps.
Can I write the text in my own words or language?
Yes. The text field takes up to 300 characters with {{code}} and {{minutes}}. Any language works; one outside GSM-7, such as Amharic or Arabic, fits 70 characters in one SMS.
Do I need 10DLC registration to send codes?
There's no registration step: codes go out from your own SIM. But codes sent by software count as A2P traffic, and in the US carriers expect that on a registered 10DLC or toll-free number, so at volume use a registered provider.
What if the person never gets the code?
GET /api/v1/messages/{messageId} shows what happened: queued while the phone is offline, sent, delivered (Android only), or failed with the reason. They can ask for a new code; a number gets five an hour.

Try it on the phone you have

Free for 1 phone and 300 texts a month. An Android pairs in two minutes.

Get started free