Sendblue Verify
Verify phone numbers by asking users to text a code to a Sendblue number
Sendblue Verify lets you confirm that a user can send messages from a phone
number. Your app shows the user a short code and a Sendblue number. The user
texts the code to that number. When Sendblue receives the reply, the
verification is marked approved.
How it works
Section titled “How it works”- Create a Verify Service.
- Create a Verification for the phone number you want to verify.
- Show the returned code and destination number to your user.
- The user texts the code to the destination number.
- Poll the Verification status or receive a webhook when it is approved.
This is an inverted-OTP flow: Sendblue does not send the first OTP message to the user. The user sends the code back to Sendblue.
Base URL
Section titled “Base URL”https://api.sendblue.comAll Verify endpoints are under:
/api/v2/verifyAuthentication
Section titled “Authentication”Verify uses your Sendblue API credentials. Include your API key and secret in the standard Sendblue request headers:
sb-api-key-id: YOUR_API_KEYsb-api-secret-key: YOUR_API_SECRETExamples in this guide use those headers:
curl https://api.sendblue.com/api/v2/verify/services \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET"Services
Section titled “Services”A Verify Service stores configuration for a group of verifications, such as code length, expiration window, and whether your account-assigned lines should be used for verification.
Service SIDs start with SV.
Create a service
Section titled “Create a service”POST /api/v2/verify/servicesRequest body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
friendly_name | string | Yes | Human-readable name for the service. |
code_length | integer | No | Verification code length, from 4 to 8 characters. Default: 6. |
ttl_seconds | integer | No | How long a verification remains pending, from 60 to 3600 seconds. Default: 300. |
use_customer_lines | boolean | No | If true, Sendblue first uses phone numbers already assigned to your account for verification, then falls back to the shared Verify pool. Default: false. |
PascalCase keys are also accepted:
{ "FriendlyName": "Production login verify", "CodeLength": 6, "TtlSeconds": 300, "UseCustomerLines": false}Example request
Section titled “Example request”curl -X POST https://api.sendblue.com/api/v2/verify/services \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "friendly_name": "Production login verify", "code_length": 6, "ttl_seconds": 300 }'Success response (201)
Section titled “Success response (201)”{ "sid": "SV7a634393b19c141ac07520602f0e38b6", "account_sid": "c6812cd8-dd8f-4558-87db-cc41...", "friendly_name": "Production login verify", "code_length": 6, "ttl_seconds": 300, "use_customer_lines": false, "date_created": "2026-06-26T18:00:00.000Z", "date_updated": "2026-06-26T18:00:00.000Z", "url": "https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6"}List services
Section titled “List services”GET /api/v2/verify/servicesExample request
Section titled “Example request”curl https://api.sendblue.com/api/v2/verify/services \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET"Success response (200)
Section titled “Success response (200)”{ "services": [ { "sid": "SV7a634393b19c141ac07520602f0e38b6", "account_sid": "c6812cd8-dd8f-4558-87db-cc41...", "friendly_name": "Production login verify", "code_length": 6, "ttl_seconds": 300, "use_customer_lines": false, "date_created": "2026-06-26T18:00:00.000Z", "date_updated": "2026-06-26T18:00:00.000Z", "url": "https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6" } ], "meta": { "page": 0, "page_size": 1, "first_page_url": "https://api.sendblue.com/api/v2/verify/services", "previous_page_url": null, "next_page_url": null }}Retrieve a service
Section titled “Retrieve a service”GET /api/v2/verify/services/:service_sidExample request
Section titled “Example request”curl https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6 \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET"Update a service
Section titled “Update a service”POST /api/v2/verify/services/:service_sidOnly include fields you want to change.
Example request
Section titled “Example request”curl -X POST https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6 \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "ttl_seconds": 600, "use_customer_lines": true }'Updates apply to future verifications. Existing pending verifications keep the settings they were issued with.
Delete a service
Section titled “Delete a service”DELETE /api/v2/verify/services/:service_sidDeleting a service prevents new verifications from being issued with that service. Existing verification history is retained.
Example request
Section titled “Example request”curl -X DELETE https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6 \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET"Success returns 204 No Content.
Verifications
Section titled “Verifications”A Verification represents one attempt to verify one phone number.
Verification SIDs start with VR.
Create a verification
Section titled “Create a verification”POST /api/v2/verify/services/:service_sid/verificationsRequest body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Phone number being verified, in E.164 format. |
hosted | object | No | Creates an origin-bound hosted widget session for this Verification. See Hosted widget. |
PascalCase is accepted for top-level keys such as To. Keys inside hosted
are strict snake_case; for example, use parent_origin, not ParentOrigin.
{ "To": "+14155551212"}Example request
Section titled “Example request”curl -X POST https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6/verifications \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "to": "+14155551212" }'Success response (201)
Section titled “Success response (201)”{ "sid": "VR51d9c7dee89c6d7627c318a773bef7e9", "service_sid": "SV7a634393b19c141ac07520602f0e38b6", "account_sid": "c6812cd8-dd8f-4558-87db-cc41...", "to": "+14155551212", "channel": "imessage", "status": "pending", "date_created": "2026-06-26T18:05:00.000Z", "date_updated": "2026-06-26T18:05:00.000Z", "expires_at": "2026-06-26T18:10:00.000Z", "url": "https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6/verifications/VR51d9c7dee89c6d7627c318a773bef7e9", "delivery_target": { "pool_number": "+17865640246", "code": "CB6DJ9", "sms_deep_link": "sms:+17865640246?body=CB6DJ9" }}Show the user:
- the
code - the
pool_number - optionally, a button or QR code using
sms_deep_link
The user should send the code from the phone number in to. A code sent from a
different phone number will not approve the verification.
Check verification status
Section titled “Check verification status”GET /api/v2/verify/services/:service_sid/verifications/:verification_sidExample request
Section titled “Example request”curl https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6/verifications/VR51d9c7dee89c6d7627c318a773bef7e9 \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET"Pending response (200)
Section titled “Pending response (200)”{ "sid": "VR51d9c7dee89c6d7627c318a773bef7e9", "service_sid": "SV7a634393b19c141ac07520602f0e38b6", "account_sid": "c6812cd8-dd8f-4558-87db-cc41...", "to": "+14155551212", "channel": "imessage", "status": "pending", "date_created": "2026-06-26T18:05:00.000Z", "date_updated": "2026-06-26T18:05:00.000Z", "expires_at": "2026-06-26T18:10:00.000Z", "url": "https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6/verifications/VR51d9c7dee89c6d7627c318a773bef7e9", "delivery_target": { "pool_number": "+17865640246", "code": "CB6DJ9", "sms_deep_link": "sms:+17865640246?body=CB6DJ9" }}Approved response (200)
Section titled “Approved response (200)”{ "sid": "VR51d9c7dee89c6d7627c318a773bef7e9", "service_sid": "SV7a634393b19c141ac07520602f0e38b6", "account_sid": "c6812cd8-dd8f-4558-87db-cc41...", "to": "+14155551212", "channel": "imessage", "status": "approved", "date_created": "2026-06-26T18:05:00.000Z", "date_updated": "2026-06-26T18:05:24.000Z", "expires_at": "2026-06-26T18:10:00.000Z", "url": "https://api.sendblue.com/api/v2/verify/services/SV7a634393b19c141ac07520602f0e38b6/verifications/VR51d9c7dee89c6d7627c318a773bef7e9"}For recently approved verifications, to is returned while the live
verification record is still available. Later terminal-state lookups may return
to: null; use the approved webhook if your backend needs the verified phone
number asynchronously.
Possible statuses:
| Status | Meaning |
|---|---|
pending | Waiting for the user to text the code. |
approved | The expected phone number sent the correct code. |
expired | The verification was not approved before its expiration time. |
canceled | The verification was canceled by Sendblue operational action. |
Recommended UI flow
Section titled “Recommended UI flow”After creating a Verification:
- Display the code clearly.
- Display the destination number clearly.
- Offer a “Text code” button using
delivery_target.sms_deep_link. - Poll the status endpoint every few seconds while the verification is pending.
- Stop polling when the status becomes
approved,expired, orcanceled. - Prefer webhooks for backend systems that need reliable asynchronous updates.
Example copy:
Text CB6DJ9 to +1 (786) 564-0246 from the phone number you want to verify.Hosted widget
Section titled “Hosted widget”Hosted Verify is an optional, Sendblue-hosted interface for the same inverted-OTP flow. It displays the sender instruction, QR code, Messages link, countdown, and verification state. Your backend opts into it when creating each Verification; it is not configured on the Verify Service.
Configure the parent origin
Section titled “Configure the parent origin”The hosted.parent_origin value identifies the website origin allowed to embed
that widget session. Set it to the exact value of window.location.origin for
the page that will contain the component.
{ "to": "+14155551212", "hosted": { "parent_origin": "https://app.example.com", "brand_name": "Acme", "accent_color": "#008BFF", "theme": "auto" }}| Parameter | Type | Required | Description |
|---|---|---|---|
parent_origin | string | Yes | Exact HTTPS origin allowed to embed the widget. |
brand_name | string | No | Brand name shown in the widget, from 1 to 60 characters. Default: Sendblue. |
accent_color | string | No | Six-digit hex brand color such as #008BFF. Sendblue may use an accessible fallback where contrast is insufficient. |
theme | string | No | light, dark, or auto. Default: auto. |
Origin rules:
- Use only the scheme, hostname, and optional port. Do not include a trailing slash, path, query string, or fragment.
- Production and remotely hosted development origins must use HTTPS.
- Use a DNS hostname for HTTPS origins.
127.0.0.1is also accepted, and HTTP is accepted only forlocalhostor127.0.0.1development origins. - Wildcards and other IP literals are rejected because browsers cannot enforce
them as exact CSP
frame-ancestorssources. - Origins are exact.
https://app.example.com,https://www.example.com, andhttps://app.example.com:8443are different origins. - A session permits one origin; wildcards and origin lists are not supported. Create each Verification with the origin for the environment that will render it.
Keep the allowed origin in trusted server configuration. Do not accept an
arbitrary parent_origin from the browser and forward it without validation.
If the embedding page does not match the configured origin, the browser refuses
to render the widget.
Create a hosted verification on your backend
Section titled “Create a hosted verification on your backend”Sendblue API credentials must remain on your backend. Associate the returned Verification SID with the signed-in user in server-side storage, then return only the browser-safe hosted values to your frontend.
app.post("/api/phone-verification", requireSignedInUser, async (req, res) => { const response = await fetch( `https://api.sendblue.com/api/v2/verify/services/${process.env.SENDBLUE_VERIFY_SERVICE_SID}/verifications`, { method: "POST", headers: { "content-type": "application/json", "sb-api-key-id": process.env.SENDBLUE_API_KEY_ID, "sb-api-secret-key": process.env.SENDBLUE_API_SECRET_KEY, }, body: JSON.stringify({ to: req.user.phone, hosted: { parent_origin: process.env.APP_ORIGIN, brand_name: "Acme", accent_color: "#008BFF", theme: "auto", }, }), }, );
if (!response.ok) { return res.status(502).json({ message: "Unable to start verification" }); }
const verification = await response.json(); await saveVerificationForUser(req.user.id, verification.sid); res.json({ verification_sid: verification.sid, hosted: verification.hosted, });});The create response adds a hosted object:
{ "sid": "VR51d9c7dee89c6d7627c318a773bef7e9", "status": "pending", "expires_at": "2026-08-17T21:45:00.000Z", "hosted": { "url": "https://api.sendblue.co/api/v2/verify/widget?session=...#token=...", "session_id": "...", "component_script": "https://api.sendblue.co/api/v2/verify/widget.js", "expires_at": "2026-08-17T21:45:00.000Z" }}Use hosted.url and hosted.component_script exactly as returned instead of
constructing them. The URL fragment contains a one-session bearer secret. Do
not log or persist the full URL, expose it to another user, or move the token
into a query parameter.
Embed the component
Section titled “Embed the component”Load the returned component script, then pass the returned hosted URL to a
sendblue-verify element:
const response = await fetch("/api/phone-verification", { method: "POST" });const { hosted } = await response.json();
await new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = hosted.component_script; script.onload = resolve; script.onerror = reject; document.head.append(script);});
const widget = document.createElement("sendblue-verify");widget.setAttribute("hosted-url", hosted.url);document.querySelector("#phone-verification").replaceChildren(widget);The component is responsive from 320 to 560 pixels wide and manages polling, retry behavior, QR delivery, Messages deep-linking, and terminal states.
If your application uses Content Security Policy, allow the origin returned by
Sendblue in both script-src and frame-src. For example:
Content-Security-Policy: script-src 'self' https://api.sendblue.co; frame-src https://api.sendblue.coHandle status securely
Section titled “Handle status securely”The component emits bubbling DOM events for presentation and analytics:
sendblue-verify-readysendblue-verify-statuswithevent.detail.statussendblue-verify-approvedsendblue-verify-expiredfor bothexpiredandcanceled; inspectevent.detail.statusto distinguish themsendblue-verify-error
Browser events are forgeable. Never grant access, change account state, or mark a user verified from a widget event alone. On approval, ask your authenticated backend to confirm the Verification status with Sendblue:
const widget = document.querySelector("sendblue-verify");
widget.addEventListener("sendblue-verify-approved", async () => { const response = await fetch("/api/phone-verification/status", { credentials: "same-origin", cache: "no-store", }); const result = await response.json(); if (result.status === "approved") showVerifiedExperience();});The backend status route must load the SID previously associated with that signed-in user and query Sendblue using backend-only credentials:
app.get("/api/phone-verification/status", requireSignedInUser, async (req, res) => { const verificationSid = await loadVerificationForUser(req.user.id); const response = await fetch( `https://api.sendblue.com/api/v2/verify/services/${process.env.SENDBLUE_VERIFY_SERVICE_SID}/verifications/${verificationSid}`, { headers: { "sb-api-key-id": process.env.SENDBLUE_API_KEY_ID, "sb-api-secret-key": process.env.SENDBLUE_API_SECRET_KEY, }, cache: "no-store", }, );
if (!response.ok) return res.status(502).json({ status: "unavailable" }); const verification = await response.json(); res.json({ status: verification.status });});For asynchronous authorization, use the signed Verify webhook
instead. In either flow, confirm that the Verification SID belongs to the
expected signed-in user before accepting approved as authoritative.
Account-assigned lines
Section titled “Account-assigned lines”By default, Verify uses Sendblue’s shared verification pool.
If your account has assigned verification numbers and your service has
use_customer_lines: true, Sendblue first uses your own active numbers for
verification. If no assigned number is available, it falls back to the shared
pool.
Using account-assigned lines is useful when you want the same lines used for verification to stay available for ongoing conversations with your users.
Webhooks
Section titled “Webhooks”You can configure a Verify webhook to receive terminal verification events.
Webhook configuration uses the existing Sendblue account webhook API:
POST /api/account/webhooksOnly one Verify webhook URL is supported per account.
Add a Verify webhook
Section titled “Add a Verify webhook”curl -X POST https://api.sendblue.com/api/account/webhooks \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "verify", "webhooks": ["https://example.com/sendblue/verify"] }'Success response (200)
Section titled “Success response (200)”{ "status": "OK", "message": "Webhooks added successfully", "webhooks": { "receive": [], "verify": [ { "url": "https://example.com/sendblue/verify", "secret": "whsec_..." } ] }}Store the returned secret. It is used to verify webhook signatures and is also
available in the Sendblue dashboard UI in your webhook settings.
Replace a Verify webhook
Section titled “Replace a Verify webhook”Use PUT /api/account/webhooks with a verify array:
curl -X PUT https://api.sendblue.com/api/account/webhooks \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{ "webhooks": { "verify": ["https://example.com/sendblue/verify"] } }'If the URL already had a secret, Sendblue preserves it so your signature verification does not break unexpectedly.
Webhook events
Section titled “Webhook events”| Event | Description |
|---|---|
verification.approved | The user texted the correct code from the expected phone number. |
verification.expired | The verification expired before approval. |
verification.canceled | The verification was canceled by Sendblue operational action. |
Approved payload:
{ "event": "verification.approved", "verification_id": "VR51d9c7dee89c6d7627c318a773bef7e9", "phone": "+14155551212", "completed_at": "2026-06-26T18:05:24.000Z"}Expired payload:
{ "event": "verification.expired", "verification_id": "VR51d9c7dee89c6d7627c318a773bef7e9", "completed_at": "2026-06-26T18:10:00.000Z"}phone is included only for verification.approved.
Webhook signatures
Section titled “Webhook signatures”If a secret is configured, Sendblue signs each Verify webhook with:
X-Sendblue-Signature: t=<unix_timestamp>,v1=<signature>The signature is:
HMAC_SHA256(secret, `${timestamp}.${raw_request_body}`)Verify the signature against the raw request body before parsing JSON.
Node.js example:
import crypto from "crypto";
function verifySendblueSignature(rawBody, signatureHeader, secret) { const parts = Object.fromEntries( signatureHeader.split(",").map((part) => { const [key, value] = part.split("="); return [key, value]; }) );
const timestamp = parts.t; const signature = parts.v1; if (!timestamp || !signature) return false;
const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex");
if (signature.length !== expected.length) return false;
return crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex") );}Recommended replay protection: reject signatures whose timestamp is more than 5 minutes old.
Rate limits and capacity
Section titled “Rate limits and capacity”Sendblue applies protective limits to keep the Verify system healthy:
- A short per-account create burst limit protects against accidental loops.
- A per-line active pending cap spreads load across available Sendblue numbers.
- Repeated wrong-code replies to the same Sendblue number can temporarily cool that number down.
Valid matching codes are not blocked by the wrong-code cooldown.
If a request is rate-limited, the response includes HTTP 429 and may include a
Retry-After header.
If no verification line is currently available, the API returns 503.
Error format
Section titled “Error format”Verify errors use this error envelope:
{ "code": 60200, "message": "Invalid request.", "more_info": "https://docs.sendblue.com/errors/60200", "status": 400}Common errors:
| HTTP | Code | Message | Meaning |
|---|---|---|---|
| 400 | 60200 | Invalid request. | Request body or parameters are invalid. |
| 401 | 20003 | Authentication failed. | API credentials are missing or invalid. |
| 404 | 60002 | Company not found for credentials. | The API key is not linked to a company. |
| 404 | 60201 | Service not found. | Service SID does not exist, was deleted, or belongs to another account. |
| 404 | 60223 | Verification not found. | Verification SID does not exist or belongs to another service/account. |
| 429 | 60207 | Rate limit exceeded; please retry later. | A protective burst limit was exceeded. |
| 503 | 60226 | Verify service pool unavailable; no active pool worker. | No eligible verification line is currently available. |
| 503 | 60227 | Verify code generation failed; please retry. | Code collision retries were exhausted. |
| 500 | 20500 | Internal server error. | Unexpected server error. |
End-to-end example
Section titled “End-to-end example”Create a service:
SERVICE=$(curl -s -X POST https://api.sendblue.com/api/v2/verify/services \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{"friendly_name":"Login Verify","code_length":6,"ttl_seconds":300}')
SERVICE_SID=$(echo "$SERVICE" | jq -r '.sid')Issue a verification:
VERIFICATION=$(curl -s -X POST "https://api.sendblue.com/api/v2/verify/services/$SERVICE_SID/verifications" \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" \ -H "Content-Type: application/json" \ -d '{"to":"+14155551212"}')
echo "$VERIFICATION" | jq '.delivery_target'Show the user the returned code and destination number.
Poll status:
VERIFICATION_SID=$(echo "$VERIFICATION" | jq -r '.sid')
curl "https://api.sendblue.com/api/v2/verify/services/$SERVICE_SID/verifications/$VERIFICATION_SID" \ -H "sb-api-key-id: YOUR_API_KEY" \ -H "sb-api-secret-key: YOUR_API_SECRET" | jq .When the user texts the correct code from the expected phone number, the status
changes to approved.
- The user must text the code from the phone number being verified.
- The verification code is only valid during the service’s TTL window.
- Sendblue does not expose a “submit code” endpoint for this flow. Approval happens when Sendblue receives the inbound message from the user.
- Use webhooks when your backend needs asynchronous notification.
- Use polling when your frontend needs to update a waiting screen.