msgbubblesDocs

OTP verification

Phone sign-in without sending a text. Your app shows the user a six-digit code and a number; they text the code to that number from their own phone — over iMessage, SMS, or WhatsApp — and msgbubbles matches the text to the code and tells you the phone is verified. No outbound OTP messages to pay for or get filtered, no carrier registration, and the same flow on every channel.

This is a separate service, not part of the messaging API. It runs on its own host, https://otp.msgbubbles.com — nothing OTP-related exists on https://api.msgbubbles.com — and takes its own otp_live_… keys: a messaging sk_live_… key is rejected with 401 unauthorized here, and vice versa. You don’t need a msgbubbles number or messaging account to use it.

How it works

  1. Your server creates a challenge for the user’s phone number and gets back a code, the number to text it to, and links that open Messages or WhatsApp with the code pre-filled.
  2. You show the code, the number, and your app’s name to the user.
  3. They send the code. When it arrives from that phone, the challenge becomes verified and they get a short reply in the same thread naming your app.
  4. Your server polls the challenge until it’s verified, then consumes it — once — and signs the user in.

Base URL and keys

All OTP endpoints live under /v1 on the OTP host:

https://otp.msgbubbles.com/v1

Every request carries an OTP API key as a bearer token. Keys look like otp_live_…, are issued when your OTP account is set up, and are shown exactly once — store yours in a secret manager. They are secrets: call this API from your server only, never from a browser or a mobile app.

Responses use the same envelopes as the messaging API — { data, request_id } on success, { error, message, request_id } on failure — and request_id is also the X-Request-Id header. See Errors & limits for the shape.

Quickstart

1. Create a challenge

POST/v1/challenges

Create a challenge
curl -X POST https://otp.msgbubbles.com/v1/challenges \
  -H "Authorization: Bearer otp_live_…" \
  -H "content-type: application/json" \
  -d '{ "phone": "+15555550123" }'
Response (201)
{
  "data": {
    "id": "Vf3nQ…",
    "code": "482913",
    "phone": "+15555550123",
    "expires_at": "2026-09-13T12:10:00.000Z",
    "send_to": {
      "number": "+18005551111",
      "display": "+1 800 555 1111",
      "sms_link": "sms:+18005551111?&body=482913"
    },
    "whatsapp": {
      "number": "+18005551111",
      "display": "+1 800 555 1111",
      "link": "https://wa.me/18005551111?text=482913"
    }
  },
  "request_id": "req_…"
}

phone is normalized to E.164 (US national formats are accepted; anything else needs a country code). The code is returned here and nowhere else. whatsapp is null when WhatsApp isn’t offered. Codes expire after 10 minutes.

2. Show the code

Display code and send_to.display, with a button on send_to.sms_link (and one on whatsapp.link when present) — one tap opens the user’s messaging app with the code already typed. Put your app’s name next to the code so the user knows what they’re signing in to.

3. Poll until verified

GET/v1/challenges/:id

Poll the challenge
curl https://otp.msgbubbles.com/v1/challenges/Vf3nQ… \
  -H "Authorization: Bearer otp_live_…"
Response
{
  "data": {
    "id": "Vf3nQ…",
    "status": "verified",
    "phone": "+15555550123",
    "expires_at": "2026-09-13T12:10:00.000Z",
    "verified_at": "2026-09-13T12:01:47.000Z",
    "consumed_at": null,
    "created_at": "2026-09-13T12:00:00.000Z"
  },
  "request_id": "req_…"
}

Polling every couple of seconds is plenty; the text usually lands within a few seconds of being sent. A challenge you didn’t create answers 404 not_found.

4. Consume it and sign the user in

POST/v1/challenges/:id/consume

Consume the verified phone
curl -X POST https://otp.msgbubbles.com/v1/challenges/Vf3nQ…/consume \
  -H "Authorization: Bearer otp_live_…"

Returns the same challenge object with status "consumed" and consumed_at set. A consume succeeds exactly once: a second call answers 409 already_consumed, so a replayed or leaked id can’t sign in twice. Treat a successful consume call as the moment the user is authenticated — look up or create your user by phone and start their session then.

Statuses

StatusMeaning
pendingWaiting for the user’s text.
verifiedThe code arrived from the user’s phone. Consume it to finish.
consumedYou already took the result; it can’t be consumed again.
expiredTen minutes passed without being consumed. Create a new challenge.

What the user sees

The code has to be sent as a bare six-digit message — nothing else in the text — from the phone number the challenge was created for. Group chats and messages with extra words are ignored. After a matching text, the user gets a reply in the same thread:

Reply on success
✅ Verified for Acme. Head back to Acme — it'll finish signing you in.

A code that doesn’t match, or arrives after the challenge expired, gets a reply saying so and asking them to request a new one. After five wrong texts a pending challenge stops matching entirely.

Errors

StatusCodeMeaning
400invalid_requestMalformed JSON, or phone is missing.
400invalid_phonephone isn’t a valid phone number.
401unauthorizedMissing or invalid OTP API key — a messaging sk_live_… key doesn’t work here.
403forbiddenValid key, suspended account.
404not_foundNo such challenge — or it belongs to a different account.
409not_verifiedConsume was called before the user’s text arrived. Keep polling.
409already_consumedThe challenge was already consumed.
410expiredThe challenge expired before it was consumed.
429rate_limitedToo many challenges for this phone or account. Wait a minute.
503unavailableVerification is temporarily unavailable. Safe to retry.

Limits

  • Codes are six digits and expire 10 minutes after creation.
  • Per phone number: 3 challenges per minute from your account, and 10 per minute across all apps using OTP.
  • Per account: 120 challenges per minute by default — ask if you need more.
  • A pending challenge stops matching after 5 wrong texts.

Integrating safely

  • Server-side only. The API key and the challenge id never go to the browser. Keep the id in the user’s server session (or a signed cookie), never in a URL, and let only that session trigger the poll and the consume.
  • Name your app. Show it next to the code, and rely on the reply doing the same — that is what makes a code someone was talked into forwarding read as what it is.
  • Consuming is the sign-in. Don’t create a session on verified; create it on a successful consume, from the phone in the response.
  • One challenge per attempt. When one expires or the user changes their number, create a new one rather than reusing the old id.