Verify API reference Live
Two calls carry every verification: create one, then check the code the user typed back. This page documents both in full, plus the read endpoints for polling a verification's status and listing recent activity. Every field below matches the wire shape exactly, including the honest three states of dispatch.
Every machine-readable error reason, with its HTTP status and what to do about it, is a separate page: Verify API error codes. This page focuses on shapes and semantics; that one is the lookup table.
Authentication
Every endpoint on this page requires a Bearer API key with the verify scope, in the Authorization header:
Authorization: Bearer mk_live_...A missing or unrecognized key returns 401 unauthorized. A key that exists but lacks the verify scope returns 403 insufficient_scope. Keys are workspace-scoped: a key from one workspace can never read, check, or list another workspace's verifications. See Authentication for key creation and scopes.
Create a verification
Creates a verification code and dispatches it over the requested channel. Creating a verification never bills your workspace, regardless of whether the send succeeds, fails, or is never checked. Only a successful check bills, exactly once per code.
Body parameters
| Field | Type | Description |
|---|---|---|
| torequired | string | The destination. An email address for the email channel, or a phone number (E.164 preferred, lightly normalized server-side) for sms. 3 to 254 characters. |
| channelrequired | enum | One of email, sms, voice, whatsapp, push, totp, sna. Only email and sms dispatch today; the rest return 422 channel_not_available. See setup times. |
| serviceIdoptional | string | A Verify Service id (vs_..., 16 alphanumeric characters after the prefix). Omit to use the workspace's default service. A workspace with no services returns 400 no_default_service. |
Code samples
curl -X POST https://api.message.com/v1/verify \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"to": "[email protected]",
"channel": "email"
}'const res = await fetch('https://api.message.com/v1/verify', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '[email protected]', channel: 'email' }),
});
const verification = await res.json();import requests
r = requests.post(
"https://api.message.com/v1/verify",
headers={"Authorization": "Bearer mk_live_..."},
json={"to": "[email protected]", "channel": "email"},
)
verification = r.json()Response shape
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"status": "pending",
"channel": "email",
"hint": "a•••@acme.com",
"dispatch": "sent",
"expiresAt": "2026-07-25T18:14:11.000Z"
}id: the verification's identifier (UUID). Pass it to the check call.status: alwayspendingon creation. There is no other value in this field for the create response.channel: echoes the requested channel.hint: a masked destination, safe to render in your UI. Email hints show the first character plus domain (a•••@acme.com); phone hints show the last four digits (•••1234).dispatch: what happened on the wire right now. See the dedicated section below.expiresAt: ISO timestamp when the code stops being acceptable. 600 seconds (10 minutes) by default, configurable per service (60 to 900 seconds).
On a rejected request, the body is a flat { error, ...context } shape instead, for example a disabled channel:
{
"error": "channel_not_available",
"channel": "voice"
}The dispatch field
dispatch is the one field on this page that is easy to document dishonestly, so here is exactly what each value means and where it comes from:
| Value | Meaning |
|---|---|
sent | The message left our systems over the requested channel. This does not guarantee inbox or handset delivery, only that dispatch succeeded on our end. |
pending_provisioning | The channel is not wired to a live send path for your workspace. In practice this does not appear today: a channel that is not live fails earlier, before dispatch, with 422 channel_not_available (see below). This value is part of the typed dispatch result and reserved for a future state where a channel is enabled but a workspace-level provisioning step (for example, a WhatsApp template pending Meta approval) has not finished. Documented as-is rather than omitted, since it is a real value the response type can carry. |
error | A real send failure after the request passed every guard, for example a provider or carrier rejection. The verification row still exists and can still be checked once the underlying issue is resolved and a new code is requested, but the user never received this particular code. |
Because the channel-liveness gate runs before dispatch, the only value you will observe from a live workspace today is sent on success, or a 422 channel_not_available response body (no dispatch field at all) for a non-live channel. error is reachable on a live channel if the underlying send provider rejects the message after we accept the request.
Check a code
Submits the code the user typed. This endpoint is dual-mode: called with a Bearer key (documented here) it is scoped to your workspace and bills $0.03 on a successful match. Called with no key at all it serves the hosted /verify screen against platform-owned rows only and is never billed; that keyless mode is not part of the authenticated API surface and is out of scope for this reference.
Body parameters
| Field | Type | Description |
|---|---|---|
| idrequired | string (uuid) | The verification id returned by the create call. (The hosted screen's equivalent field is named verifyId; the authenticated API accepts id.) |
| coderequired | string | The code the user submitted. 4 to 8 digits, matching the service's codeLength. |
Code samples
curl -X POST https://api.message.com/v1/verify/check \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"code": "482913"
}'const res = await fetch('https://api.message.com/v1/verify/check', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: verification.id, code: userSubmittedCode }),
});
const result = await res.json();check = requests.post(
"https://api.message.com/v1/verify/check",
headers={"Authorization": "Bearer mk_live_..."},
json={"id": verification["id"], "code": user_submitted_code},
)
result = check.json()Response shape
A correct code returns 200 and bills the workspace:
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"verified": true
}An incorrect or otherwise unusable code also returns 200 (not an error status), with verified: false and a machine-readable reason. This is the detail worth calling out explicitly: already_used, expired, and too_many_attempts are all 200, never 404 or 410. Only a genuinely unknown or cross-workspace id returns 404.
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"verified": false,
"reason": "bad_code"
}The full set of reason values, exactly when each fires, and what to do about it lives on Verify API error codes.
A wrong code does not fail the request. Check verified in the body, not the HTTP status. This is the single most common integration mistake against this endpoint.
Get a verification
Reads the current status of a verification without consuming an attempt. Useful for a polling UI that wants to show "code sent" or "expired" states without a check call. Scoped to the calling workspace; an unknown or cross-workspace id returns 404 not_found.
Response shape
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"channel": "email",
"status": "pending",
"hint": "a•••@acme.com",
"appName": "Acme",
"serviceId": "vs_7hK2pQmN4rT8sX1c",
"createdAt": "2026-07-25T18:04:11.000Z",
"expiresAt": "2026-07-25T18:14:11.000Z",
"consumedAt": null
}status here reflects the row's current lifecycle state (pending, verified, expired, or failed after the attempt cap is hit), not just pending like the create response. The verification code itself is never included in this or any other response, log line, or webhook payload.
List verifications
Returns recent verifications for the workspace, newest first: a log feed for a dashboard or support tool. Not paginated beyond limit; for high-volume workspaces, narrow with serviceId and query more frequently rather than relying on an offset.
Query parameters
| Field | Type | Description |
|---|---|---|
| limitoptional | integer | Max rows to return. Default 50, maximum 200. |
| serviceIdoptional | string | Narrow the feed to one Verify Service (vs_...). An id that does not resolve in this workspace returns 404 service_not_found. |
curl -X GET 'https://api.message.com/v1/verify?limit=50' \
-H 'Authorization: Bearer mk_live_...'{
"verifications": [
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"channel": "email",
"status": "verified",
"hint": "a•••@acme.com",
"appName": "Acme",
"serviceId": "vs_7hK2pQmN4rT8sX1c",
"createdAt": "2026-07-25T18:04:11.000Z",
"expiresAt": "2026-07-25T18:14:11.000Z",
"consumedAt": "2026-07-25T18:05:02.000Z"
}
]
}Rate limits
Rate limiting on Verify is layered, and each layer produces the same 429 rate_limited shape with a different scope value so you can tell which ceiling you hit:
| Layer | Default | scope value | Applies to |
|---|---|---|---|
| Per-API-key request budget | 10 requests/second sustained, burst 50 | n/a (plain 429 from the request layer, before the Verify pipeline runs) | Every request against any endpoint on this page, keyed by API key. |
| Per-service velocity, minute | 10 sends/minute (perMinuteCap, configurable per service) | workspace | POST /v1/verify only, counted per Verify Service. |
| Per-service velocity, day | 1,000 sends/day (perDayCap, configurable per service) | workspace | POST /v1/verify only, counted per Verify Service. Checked in the same guard as the per-minute cap; both share the scope: "workspace" value. |
| Workspace daily ceiling | 5,000 sends/day, across every service in the workspace | workspace_ceiling | POST /v1/verify only. Exists so adding more services cannot multiply the per-service caps past what the workspace is trusted for. |
| Per-destination throttle | 3 sends per destination per 60 seconds, plus 10 sends per source IP per 60 seconds | destination | POST /v1/verify only. Stops one phone number or email address, or one abusive caller IP, from being pumped with codes regardless of which service or workspace is sending. |
Checks the layers run in order, top to bottom, plus a country allowlist and credit-balance gate ahead of the velocity checks; the full guard order (with every status code) is on Verify API error codes. The per-API-key request budget is infrastructure-level and applies uniformly; the other three are specific to POST /v1/verify and exist to bound send volume, not read volume, so GET /v1/verify, GET /v1/verify/:id, and POST /v1/verify/check are not subject to them (only to the per-key request budget). These layers exist to stop SMS pumping and OTP abuse; see the Verify fraud guide for the reasoning and real numbers behind each default.
Common pitfalls
- Don't treat a 200 as success on the check call. Read
verified. A wrong code, an expired code, and an already-consumed code all return200. - A verification never bills on creation. Retry
POST /v1/verifyfreely if a send appears to fail; only a successful check costs money. - The velocity caps are per Verify Service, not per workspace, except for the workspace daily ceiling, which is the backstop across all of them combined.
channel_not_availablefires before country, velocity, or balance checks. A disabled-channel call never touches your credit balance or your rate-limit budget.