Documentation
Example — a signup flow
A complete phone-verified signup in Node/Express and Python/Flask, with the error handling a real funnel needs.
Two working backends, both doing the same thing: send a code, check it, and record the result. The comments mark the two places integrations most often go wrong — the key and the id both belong on your server.
Node / Express
// Your backend. Never the browser — an API key in browser code is
// an API key every visitor has.
const CALLABLE = "https://api.callable.com.au";
const KEY = process.env.CALLABLE_API_KEY;
async function callable(path, body) {
const res = await fetch(CALLABLE + path, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
throw Object.assign(new Error(json.error.message), { code: json.error.code });
}
return json;
}
app.post("/signup/phone", async (req, res) => {
try {
const { id } = await callable("/v1/otp/send", {
phone: req.body.phone,
country: "AU",
channel: "sms",
metadata: { user_id: req.user.id },
});
// Keep the id server-side. Never send it to the client.
req.session.verificationId = id;
res.json({ sent: true });
} catch (err) {
if (err.code === "resend_too_soon" || err.code === "phone_rate_limited") {
return res.status(429).json({ error: err.message });
}
if (err.code === "invalid_phone") {
return res.status(400).json({ error: "That doesn't look like a phone number." });
}
throw err;
}
});
app.post("/signup/verify", async (req, res) => {
const result = await callable("/v1/otp/check", {
verification_id: req.session.verificationId,
code: req.body.code,
});
if (!result.verified) {
return res.status(400).json({
error: "That code is not right.",
attemptsRemaining: result.attempts_remaining,
});
}
// Callable does not remember this. You do.
await db.users.update(req.user.id, { phone_verified_at: new Date() });
res.json({ verified: true });
});
Python / Flask
# Your backend. Never the browser.
import os
import requests
from flask import session, request, jsonify
CALLABLE = "https://api.callable.com.au"
KEY = os.environ["CALLABLE_API_KEY"]
class CallableError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
def callable_post(path, body):
res = requests.post(
f"{CALLABLE}{path}",
json=body,
headers={"Authorization": f"Bearer {KEY}"},
timeout=30,
)
data = res.json()
if not res.ok:
raise CallableError(data["error"]["code"], data["error"]["message"])
return data
@app.post("/signup/phone")
def send_code():
try:
result = callable_post("/v1/otp/send", {
"phone": request.json["phone"],
"country": "AU",
"channel": "sms",
"metadata": {"user_id": current_user.id},
})
except CallableError as err:
if err.code == "invalid_phone":
return jsonify(error="That doesn't look like a phone number."), 400
if err.code in ("resend_too_soon", "phone_rate_limited"):
return jsonify(error=str(err)), 429
raise
session["verification_id"] = result["id"] # keep it server-side
return jsonify(sent=True)
@app.post("/signup/verify")
def check_code():
result = callable_post("/v1/otp/check", {
"verification_id": session["verification_id"],
"code": request.json["code"],
})
if not result["verified"]:
return jsonify(
error="That code is not right.",
attempts_remaining=result.get("attempts_remaining"),
), 400
# Callable does not remember this. You do.
db.users.mark_phone_verified(current_user.id)
return jsonify(verified=True)
Getting it right
- Keep the key server-side. Environment variable or secret manager, never a repository, a browser bundle, or a mobile app.
- Keep the
idserver-side too. Store it in the session. A client that can choose which verification is being checked is a client that can skip verification. - Store the verification result yourself. CallableAI does not track that a number is verified for your product.
- Handle
verified: falseas a normal outcome, not an exception. Showattempts_remainingso your user knows where they stand. - Re-read the
phonewe return the first time you integrate. It confirms yourcountryhandling is right. - Expect the terminal statuses.
expiredandfailedare ordinary in a real signup funnel; offer a resend rather than an error page. - Name your keys for where they run — "Signup form (production)". You will want to know which one to rotate.
- Rotate by creating first. Create the new key, deploy it, then revoke the old one; revocation takes effect on the very next request.
Support
Something this reference does not cover? Email support@callable.com.au.