skills/twilio/twilio-verify/SKILL.md
Verify: 2FA SMS/voice/email, TOTP, phone verification, Verify Guard fraud prevention, SNA
npx skillsauth add alphaonedev/openclaw-graph twilio-verifyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Enable OpenClaw to implement and operate Twilio Verify (V2) in production: SMS/voice/email OTP, TOTP, custom channels, phone verification, Verify Fraud Guard (risk scoring + blocking), and Silent Network Authentication (SNA) where available. This skill focuses on:
AC...)VA...) created in Twilio Console → Verify → Servicestwilio npm package 4.22.0twilio Python package 9.0.5curl 8.5.0jq 1.7ngrok 3.13.1 for webhook testingopenssl 3.0.13 for signature verification utilitiesPrefer API Key auth over Auth Token for server-side apps.
SK...)Environment variables expected by examples:
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (if using Auth Token)TWILIO_API_KEY_SID=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTWILIO_API_KEY_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxA Verify Service (VA...) is the policy boundary for:
Treat a Verify Service as an environment-scoped resource:
VA_prod... for productionVA_staging... for stagingYour application should:
Common channels:
sms: OTP via SMScall: OTP via voice call (TwiML-driven by Twilio)email: OTP via email (Verify email channel or custom)totp: time-based one-time password (app-based)whatsapp: WhatsApp OTP (requires WhatsApp enablement)custom: your own delivery mechanism (push, in-app, etc.)Channel selection should be policy-driven:
smscall fallback for deliverabilityemail for account recovery or when phone is unavailabletotp for high-assurance accountsTwilio expects phone numbers in E.164 format: +14155552671.
Do not accept raw user input directly. Normalize and validate:
google-libphonenumber or libphonenumber-js)Verify has built-in rate limiting, but you should also implement:
Fraud Guard helps detect:
Integrate Fraud Guard decisions into your auth flow:
Use webhooks for:
Design webhooks as:
SNA verifies a user’s phone number via carrier network signals without OTP entry (where supported). Treat it as:
Repository: https://github.com/twilio/twilio-python
PyPI: pip install twilio · Supported: Python 3.7–3.13
from twilio.rest import Client
client = Client()
SERVICE_SID = os.environ["TWILIO_VERIFY_SERVICE_SID"]
# Start verification (SMS / WhatsApp / email / TOTP)
verification = client.verify.v2.services(SERVICE_SID) \
.verifications.create(to="+15558675309", channel="sms")
print(verification.status)
# Check code
check = client.verify.v2.services(SERVICE_SID) \
.verification_checks.create(to="+15558675309", code="123456")
print(check.status) # "approved" | "pending"
Source: twilio/twilio-python — verify
sudo apt-get update
sudo apt-get install -y curl jq ca-certificates gnupg
# Node.js 20.x (NodeSource)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # v20.11.1 (or later 20.x)
npm -v
# Python 3.12 (deadsnakes PPA)
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt-get update
sudo apt-get install -y python3.12 python3.12-venv python3.12-dev
python3.12 --version
sudo dnf install -y curl jq nodejs python3.12 python3.12-devel
node -v
python3.12 --version
brew update
brew install node@20 [email protected] jq curl openssl@3
# Ensure PATH includes brew Node/Python
node -v
python3.12 --version
mkdir -p verify-service && cd verify-service
npm init -y
npm install [email protected] [email protected] [email protected] [email protected]
npm install --save-dev [email protected] [email protected] @types/[email protected]
mkdir -p verify-service-py && cd verify-service-py
python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip==24.0
pip install twilio==9.0.5 fastapi==0.109.2 uvicorn==0.27.1 pydantic==2.6.1
Create a local env file (do not commit):
/etc/openclaw/twilio-verify.env (production) or ./.env (local)Example (local):
cat > ./.env <<'EOF'
TWILIO_ACCOUNT_SID=AC2f7c2c6b2d2f4a1b9a0b3c1d2e3f4a5
TWILIO_API_KEY_SID=YOUR_API_KEY_SID
TWILIO_API_KEY_SECRET=9b2c3d4e5f60718293a4b5c6d7e8f9a0
TWILIO_VERIFY_SERVICE_SID=VA0a1b2c3d4e5f60718293a4b5c6d7e8f
TWILIO_AUTH_TOKEN=use_api_key_in_prod_if_possible
APP_ENV=local
EOF
Load it:
set -a
source ./.env
set +a
Core operation: create a Verification.
Node example (send):
// src/send.ts
import twilio from "twilio";
const accountSid = process.env.TWILIO_ACCOUNT_SID!;
const apiKeySid = process.env.TWILIO_API_KEY_SID!;
const apiKeySecret = process.env.TWILIO_API_KEY_SECRET!;
const verifyServiceSid = process.env.TWILIO_VERIFY_SERVICE_SID!;
const client = twilio(apiKeySid, apiKeySecret, { accountSid });
export async function sendOtp(to: string, channel: "sms" | "call" | "email") {
const verification = await client.verify.v2
.services(verifyServiceSid)
.verifications.create({
to,
channel,
locale: "en",
});
return {
sid: verification.sid,
status: verification.status, // "pending"
to: verification.to,
channel: verification.channel,
};
}
Create a Verification Check with the user-provided code.
Node example (check):
// src/check.ts
import twilio from "twilio";
const accountSid = process.env.TWILIO_ACCOUNT_SID!;
const apiKeySid = process.env.TWILIO_API_KEY_SID!;
const apiKeySecret = process.env.TWILIO_API_KEY_SECRET!;
const verifyServiceSid = process.env.TWILIO_VERIFY_SERVICE_SID!;
const client = twilio(apiKeySid, apiKeySecret, { accountSid });
export async function checkOtp(to: string, code: string) {
const check = await client.verify.v2
.services(verifyServiceSid)
.verificationChecks.create({ to, code });
return {
sid: check.sid,
status: check.status, // "approved" or "pending"/"canceled"
valid: check.valid,
};
}
Enforcement rule (typical):
status === "approved" and valid === trueUse Verify TOTP for app-based codes. Typical flow:
Note: Twilio Verify TOTP APIs are part of Verify V2 “Entities/Factors” model. Ensure your account has access and you’re using the correct endpoints.
Operational guidance:
Use Verify “custom” channel when you deliver the code yourself but want Twilio to manage:
Pattern:
channel=customThis is useful when:
Use Fraud Guard signals to:
Implementation pattern:
Combine:
Recommended minimums:
Use:
Store:
This section assumes direct REST usage via curl and helper library usage. Twilio does not provide an official “twilio verify” CLI with full parity; use REST calls or helper SDKs.
Endpoint:
POST https://verify.twilio.com/v2/Services/{ServiceSid}/VerificationsAuth:
Flags/fields (important):
To (string): destination (+14155552671 or email)Channel (string): sms|call|email|whatsapp|customLocale (string): e.g. en, es, frChannelConfiguration.* (object): channel-specific config (varies)CustomFriendlyName (string): label for logs/UXRateLimits.* (object): service-level overrides (if enabled)RiskCheck / Fraud Guard fields (if enabled; account-dependent)Example (SMS):
curl -sS -X POST "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" \
--data-urlencode "To=+14155552671" \
--data-urlencode "Channel=sms" \
--data-urlencode "Locale=en"
Example (Voice call):
curl -sS -X POST "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" \
--data-urlencode "To=+14155552671" \
--data-urlencode "Channel=call" \
--data-urlencode "Locale=en"
Example (Email):
curl -sS -X POST "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" \
--data-urlencode "[email protected]" \
--data-urlencode "Channel=email" \
--data-urlencode "Locale=en"
Endpoint:
POST https://verify.twilio.com/v2/Services/{ServiceSid}/VerificationCheckFields:
To (string): same destination used for sendCode (string): user-provided OTPVerificationSid (string): optional in some flows; prefer To+Code unless you store SIDExample:
curl -sS -X POST "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/VerificationCheck" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" \
--data-urlencode "To=+14155552671" \
--data-urlencode "Code=123456"
Endpoint:
GET https://verify.twilio.com/v2/Services/{ServiceSid}/VerificationsQuery params (common):
To (string)Status (string): pending|approved|canceledChannel (string)DateCreated (date filter; Twilio-style)PageSize (int): up to 1000 depending on endpointExample:
curl -sS "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications?To=%2B14155552671&PageSize=50" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" | jq .
Endpoint:
GET https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid}curl -sS "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications/VExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" | jq .
Endpoint:
POST https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid} with Status=canceledcurl -sS -X POST "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications/VExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" \
--data-urlencode "Status=canceled"
Endpoint:
GET https://verify.twilio.com/v2/Services/{ServiceSid}curl -sS "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" | jq .
twilio(apiKeySid, apiKeySecret, { accountSid, region, edge, logLevel })
Important options:
accountSid: required when using API Key authregion: e.g. us1, ie1, au1 (data residency/latency)edge: e.g. ashburn, dublin (latency optimization)logLevel: debug|info|warn|error (avoid debug in prod)Example:
import twilio from "twilio";
const client = twilio(process.env.TWILIO_API_KEY_SID!, process.env.TWILIO_API_KEY_SECRET!, {
accountSid: process.env.TWILIO_ACCOUNT_SID!,
region: "us1",
edge: "ashburn",
});
Path:
/etc/openclaw/skills/twilio/twilio-verify.toml# /etc/openclaw/skills/twilio/twilio-verify.toml
[twilio]
account_sid = "AC2f7c2c6b2d2f4a1b9a0b3c1d2e3f4a5"
auth_mode = "api_key" # "api_key" | "auth_token"
api_key_sid_env = "TWILIO_API_KEY_SID"
api_key_secret_env = "TWILIO_API_KEY_SECRET"
auth_token_env = "TWILIO_AUTH_TOKEN"
region = "us1"
edge = "ashburn"
[verify]
service_sid = "VA0a1b2c3d4e5f60718293a4b5c6d7e8f"
default_channel = "sms"
fallback_channels = ["call", "email"]
default_locale = "en"
code_ttl_seconds = 600
[rate_limits]
# App-level throttles (in addition to Twilio)
send_per_ip_per_10m = 5
send_per_to_per_10m = 3
check_per_identity_per_10m = 5
cooldown_seconds_after_failed_check = 60
[fraud_guard]
enabled = true
block_on_high_risk = true
step_up_on_medium_risk = true
step_up_channel = "totp"
[logging]
redact_fields = ["to", "email", "code", "auth_token", "api_key_secret"]
log_level = "info"
Path:
./config/verify.config.json{
"twilio": {
"region": "us1",
"edge": "ashburn"
},
"verify": {
"serviceSid": "VA0a1b2c3d4e5f60718293a4b5c6d7e8f",
"defaultLocale": "en",
"channels": ["sms", "call", "email"]
},
"security": {
"hmacKeyEnv": "VERIFY_DESTINATION_HMAC_KEY",
"webhookAuthTokenEnv": "TWILIO_AUTH_TOKEN"
}
}
Path:
/etc/systemd/system/openclaw-verify.service[Unit]
Description=OpenClaw Verify Gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=openclaw
Group=openclaw
EnvironmentFile=/etc/openclaw/twilio-verify.env
WorkingDirectory=/opt/openclaw/verify-gateway
ExecStart=/usr/bin/node /opt/openclaw/verify-gateway/dist/server.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/openclaw /var/log/openclaw
AmbientCapabilities=
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
Even when using Verify, you may need delivery telemetry. Pattern:
to hash + timestamp window + verification SIDPipeline:
POST /verify/send → Twilio Verify creates verification/webhooks/sms-statusMessageSid, MessageStatus, ErrorCode (e.g., 30003)If SMS fails:
If you already have IVR state machines:
If you need branded email beyond Verify templates:
custom channel to generate codeSendGrid dynamic template example (Handlebars):
{
"personalizations": [
{
"to": [{ "email": "[email protected]" }],
"dynamic_template_data": {
"code": "123456",
"ttl_minutes": 10
}
}
],
"from": { "email": "[email protected]", "name": "Example Security" },
"template_id": "d-13b8f94f2b2a4c4f9a8d0a1b2c3d4e5f"
}
In pipeline:
Store:
user_iddestination_e164 (encrypted at rest)destination_hash (HMAC for logs)verify_service_sidlast_verification_sidverified_atfailed_attemptscooldown_untilDo not store OTP codes.
Handle Twilio errors by code, not by string matching, but include exact messages for operator recognition.
Message:
Twilio could not authenticate the request. Please check your credentials.
Root causes:
accountSid in SDK initFix:
{ accountSid }Message:
Rate limit exceeded
Root causes:
Fix:
Message:
The 'To' number +1415555 is not a valid phone number.
Root causes:
Fix:
Message (Messaging):
Unreachable destination handset
Root causes:
Fix:
Message:
Invalid parameter: Channel
Root causes:
Fix:
Message:
Max check attempts reached
Root causes:
Fix:
Message:
Verification expired
Root causes:
Fix:
Typical log:
Error: Twilio Request Validation Failed.
Root causes:
Fix:
X-Forwarded-Host, X-Forwarded-Proto)Message (Voice debugger):
HTTP retrieval failure
Root causes:
Fix:
Message:
Attempt to send to unsubscribed recipient
Root causes:
Fix:
Validate Twilio signatures for any inbound webhook.
Node example:
import twilio from "twilio";
import type { Request, Response } from "express";
export function validateTwilioWebhook(req: Request, res: Response, next: Function) {
const authToken = process.env.TWILIO_AUTH_TOKEN!;
const signature = req.header("X-Twilio-Signature") || "";
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const isValid = twilio.validateRequest(authToken, signature, url, req.body);
if (!isValid) return res.status(403).send("Forbidden");
next();
}
Operational notes:
app.set('trust proxy', true) and reconstruct URL using forwarded headers.Set region and edge in SDK init.
Expected impact:
Measure:
sendOtp and checkOtp durationsPhone parsing can be expensive at scale.
(raw, defaultCountry).Client UX:
This reduces:
Twilio Verify “send” is not inherently idempotent across repeated calls. Implement app-level idempotency:
sha256(user_id + destination + channel + floor(now/30s))This prevents accidental double-sends from:
Implement deterministic fallback:
30003 or no delivery within 20s → VoiceDo not automatically fallback without user consent in some jurisdictions; ensure compliance.
Locale based on user preference.When verifying phone for account linking:
If SNA is enabled:
// src/server.ts
import express from "express";
import twilio from "twilio";
import { z } from "zod";
import pino from "pino";
const log = pino({ level: process.env.LOG_LEVEL || "info" });
const app = express();
app.use(express.json());
const client = twilio(process.env.TWILIO_API_KEY_SID!, process.env.TWILIO_API_KEY_SECRET!, {
accountSid: process.env.TWILIO_ACCOUNT_SID!,
region: "us1",
edge: "ashburn",
});
const serviceSid = process.env.TWILIO_VERIFY_SERVICE_SID!;
const SendSchema = z.object({
to: z.string().min(5),
channel: z.enum(["sms", "call", "email"]).default("sms"),
});
const CheckSchema = z.object({
to: z.string().min(5),
code: z.string().min(4).max(10),
});
app.post("/verify/send", async (req, res) => {
const { to, channel } = SendSchema.parse(req.body);
// TODO: enforce app-level rate limits here (Redis token bucket)
const v = await client.verify.v2.services(serviceSid).verifications.create({
to,
channel,
locale: "en",
});
log.info({ verificationSid: v.sid, channel: v.channel }, "verify_send");
res.json({ sid: v.sid, status: v.status });
});
app.post("/verify/check", async (req, res) => {
const { to, code } = CheckSchema.parse(req.body);
const c = await client.verify.v2.services(serviceSid).verificationChecks.create({ to, code });
log.info({ checkSid: c.sid, status: c.status, valid: c.valid }, "verify_check");
if (c.status === "approved" && c.valid) {
// Issue session token, mark verified, etc.
return res.json({ ok: true });
}
return res.status(401).json({ ok: false });
});
app.listen(3000, () => log.info("listening on :3000"));
Run:
npx tsx src/server.ts
curl -sS -X POST http://localhost:3000/verify/send -H 'content-type: application/json' \
-d '{"to":"+14155552671","channel":"sms"}' | jq .
Pseudo-logic:
type DeliverySignal = { smsFailed: boolean; smsTimedOut: boolean };
function chooseChannel(signal: DeliverySignal) {
if (signal.smsFailed || signal.smsTimedOut) return "call";
return "sms";
}
Operationally:
failed with 30003delivered (not always available for SMS)curl -sS -X POST "https://verify.twilio.com/v2/Services/${TWILIO_VERIFY_SERVICE_SID}/Verifications" \
-u "${TWILIO_API_KEY_SID}:${TWILIO_API_KEY_SECRET}" \
--data-urlencode "[email protected]" \
--data-urlencode "Channel=custom" | jq .
VerificationCheck.Flow:
Key production detail:
When user changes phone number:
import express from "express";
import twilio from "twilio";
import crypto from "crypto";
const app = express();
// For some frameworks you may need raw body; adjust accordingly.
app.use(express.urlencoded({ extended: false }));
const seen = new Set<string>(); // replace with Redis in prod
app.post("/webhooks/verify-events", (req, res) => {
const authToken = process.env.TWILIO_AUTH_TOKEN!;
const signature = req.header("X-Twilio-Signature") || "";
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const ok = twilio.validateRequest(authToken, signature, url, req.body);
if (!ok) return res.status(403).send("Forbidden");
const eventSid = req.body.Sid || req.body.EventSid || "";
const dedupeKey = crypto.createHash("sha256").update(eventSid).digest("hex");
if (seen.has(dedupeKey)) return res.status(200).send("ok");
seen.add(dedupeKey);
// Persist event, update metrics, etc.
return res.status(200).send("ok");
});
| Task | Command / API | Key flags/fields |
|---|---|---|
| Send OTP | POST /v2/Services/{VA}/Verifications | To, Channel, Locale |
| Check OTP | POST /v2/Services/{VA}/VerificationCheck | To, Code |
| List verifications | GET /v2/Services/{VA}/Verifications | To, Status, Channel, PageSize |
| Fetch verification | GET /v2/Services/{VA}/Verifications/{VE} | n/a |
| Cancel verification | POST /v2/Services/{VA}/Verifications/{VE} | Status=canceled |
| Auth (preferred) | API Key | SK... + secret + accountSid |
| Common errors | Twilio codes | 20003, 20429, 21211, 30003, 60202, 60203 |
| Webhook security | Signature validation | X-Twilio-Signature, exact URL |
twilio-core-auth (Account SID + API Key/Auth Token handling)twilio-webhooks (signature validation, retry/idempotency patterns)pii-handling (redaction, encryption at rest)rate-limiting (Redis token bucket / leaky bucket)twilio-messaging (delivery telemetry, STOP handling, 10DLC considerations)twilio-voice (voice fallback, call status callbacks)sendgrid-transactional (custom email channel delivery, bounce handling)studio-flows (optional orchestration for complex verification journeys)auth-otp-generic (OTP flows without Twilio-managed policy)firebase-phone-auth (phone verification managed by another provider)okta-verify (factor-based verification with enterprise IAM)tools
Root web development: project structure, tooling selection, deployment decisions
development
WebAssembly: Rust/Go/C to WASM, wasm-bindgen, Emscripten, WASM Component Model
development
Vue 3: Composition API script setup, Pinia, Vue Router 4, SFCs, Vite, Nuxt 3
tools
Tailwind CSS 4: utility classes, config, JIT, arbitrary values, darkMode, plugins, shadcn/ui