Verify Services API Live
A Verify Service groups the settings for one use case, brand, or app: enabled channels, code length and TTL, country allowlist, send caps, SMS template variant, and custom email copy. This page covers creating, listing, updating, deleting, and previewing services, plus the webhook endpoints scoped to them. Every endpoint below accepts the same Bearer mk_ key with the verify scope you already use to send and check codes.
These endpoints also accept a dashboard admin session (JWT) with the Verify feature enabled, which is how the console's Settings pages call the same routes. That session-based mode is not documented here: this page covers the API-key-reachable surface only, since that is what your integration uses.
Authentication
Authorization: Bearer mk_live_... with the verify scope, same as the rest of the Verify API. A missing or invalid key returns 401 unauthorized; a valid key without the verify scope returns 403 insufficient_scope.
Create a service
The first service created in a workspace automatically becomes the default (the service POST /v1/verify resolves to when no serviceId is given). Later services must be made default explicitly, with a PATCH.
Body parameters
| Field | Type | Description |
|---|---|---|
| friendlyNamerequired | string | 1 to 30 characters. Appears in the default SMS body and email subject as the sender name. |
| enabledChannelsoptional | array of enum | Any of email, sms, voice, whatsapp, push, totp, sna. At least one entry if provided. Including a non-live channel (anything but email/sms/voice) returns 422 channel_not_available with the offending channel named. Defaults to ["email", "sms", "voice"] (the live channel set) if omitted. |
| codeLengthoptional | integer | 4 to 8. Default 6. |
| codeTtlSecondsoptional | integer | 60 to 900. Default 600 (10 minutes). |
| allowedCountriesoptional | array of string | ISO country codes, or "*" for no restriction. Default ["US", "CA"]. Ignored for the email channel. |
| perMinuteCapoptional | integer | Minimum 1. Default 10. |
| perDayCapoptional | integer | Minimum 1. Default 1000. |
| smsTemplateVariantoptional | enum | One of standard, short, secure. Default standard. |
| emailSubjectoptional | string or null | Up to 120 characters. Overrides the default generated subject line. |
| emailBodyHtmloptional | string or null | Up to 20,000 characters. Overrides the default generated email body. |
| notesoptional | string or null | Up to 500 characters. Internal notes, not shown to end users. |
curl -X POST https://api.message.com/v1/verify/services \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"friendlyName": "Acme",
"enabledChannels": ["email", "sms", "voice"],
"codeLength": 6,
"allowedCountries": ["US", "CA"]
}'const res = await fetch('https://api.message.com/v1/verify/services', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ friendlyName: 'Acme' }),
});
const { service } = await res.json();{
"service": {
"id": "vs_7hK2pQmN4rT8sX1c",
"friendlyName": "Acme",
"isDefault": true,
"enabledChannels": ["email", "sms", "voice"],
"codeLength": 6,
"codeTtlSeconds": 600,
"allowedCountries": ["US", "CA"],
"perMinuteCap": 10,
"perDayCap": 1000,
"smsTemplateVariant": "standard",
"emailSubject": null,
"emailBodyHtml": null,
"notes": null,
"createdAt": "2026-07-25T18:04:11.000Z",
"updatedAt": "2026-07-25T18:04:11.000Z"
}
}List services
All services in the calling workspace, no pagination.
curl -X GET https://api.message.com/v1/verify/services \
-H 'Authorization: Bearer mk_live_...'{
"services": [
{
"id": "vs_7hK2pQmN4rT8sX1c",
"friendlyName": "Acme",
"isDefault": true,
"enabledChannels": ["email", "sms", "voice"],
"codeLength": 6,
"codeTtlSeconds": 600,
"allowedCountries": ["US", "CA"],
"perMinuteCap": 10,
"perDayCap": 1000,
"smsTemplateVariant": "standard",
"emailSubject": null,
"emailBodyHtml": null,
"notes": null,
"createdAt": "2026-07-25T18:04:11.000Z",
"updatedAt": "2026-07-25T18:04:11.000Z"
}
]
}Get a service
Returns a single service by its vs_... id. An id that does not resolve in this workspace returns 404 service_not_found.
{
"service": {
"id": "vs_7hK2pQmN4rT8sX1c",
"friendlyName": "Acme",
"isDefault": true,
"enabledChannels": ["email", "sms", "voice"],
"codeLength": 6,
"codeTtlSeconds": 600,
"allowedCountries": ["US", "CA"],
"perMinuteCap": 10,
"perDayCap": 1000,
"smsTemplateVariant": "standard",
"emailSubject": null,
"emailBodyHtml": null,
"notes": null,
"createdAt": "2026-07-25T18:04:11.000Z",
"updatedAt": "2026-07-25T18:04:11.000Z"
}
}Update a service
Every body field is optional; only the fields you send are changed. Adding a non-live channel to enabledChannels returns 422 channel_not_available, same as create. Unset fields keep their current value.
Additional field
| Field | Type | Description |
|---|---|---|
| isDefaultoptional | boolean | Set true to make this service the workspace default (swaps atomically off the previous default). Setting false on the current default returns 409 default_required: a workspace must always have exactly one default, so you make a different service default instead of un-defaulting this one. |
curl -X PATCH https://api.message.com/v1/verify/services/vs_7hK2pQmN4rT8sX1c \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"perMinuteCap": 30,
"smsTemplateVariant": "short"
}'{
"service": {
"id": "vs_7hK2pQmN4rT8sX1c",
"friendlyName": "Acme",
"isDefault": true,
"enabledChannels": ["email", "sms", "voice"],
"codeLength": 6,
"codeTtlSeconds": 600,
"allowedCountries": ["US", "CA"],
"perMinuteCap": 10,
"perDayCap": 1000,
"smsTemplateVariant": "standard",
"emailSubject": null,
"emailBodyHtml": null,
"notes": null,
"createdAt": "2026-07-25T18:04:11.000Z",
"updatedAt": "2026-07-25T18:04:11.000Z"
}
}Preview a template
Renders the exact template a real send would produce for this service, using a sample code that respects the service's codeLength. This calls the same render functions the dispatcher uses, so the preview can never drift from a real message.
Query parameters
| Field | Type | Description |
|---|---|---|
| channelrequired | enum | One of the seven VerifyChannel values. Only sms, voice, and email have a template today; the rest return 422 preview_not_available. |
| variantoptional | enum | SMS only. One of standard, short, secure. Defaults to the service's configured smsTemplateVariant. |
curl -X GET 'https://api.message.com/v1/verify/services/vs_7hK2pQmN4rT8sX1c/preview?channel=sms' \
-H 'Authorization: Bearer mk_live_...'{
"channel": "sms",
"text": "Your Acme verification code is 123456. It expires in 10 minutes."
}{
"channel": "email",
"subject": "123456 is your Acme verification code",
"html": "<p>Your Acme verification code is <strong>123456</strong>...</p>",
"text": "Your Acme verification code is 123456..."
}{
"error": "preview_not_available",
"channel": "whatsapp"
}Delete a service
Deletes a non-default service. Deleting the workspace's default service returns 409 default_service (make a different service default first, then delete this one). An id that does not resolve returns 404 not_found.
curl -X DELETE https://api.message.com/v1/verify/services/vs_7hK2pQmN4rT8sX1c \
-H 'Authorization: Bearer mk_live_...'{ "deleted": true }Service endpoint errors
| Status | Reason | Where | When |
|---|---|---|---|
400 | validation_error | Create, update | Body fails schema validation. |
422 | channel_not_available | Create, update | enabledChannels includes a channel that is not live. Echoes channel. |
404 | service_not_found | Get, update, preview | Id does not resolve in this workspace. |
404 | not_found | Delete | Id does not resolve in this workspace. Delete uses the plain not_found reason rather than service_not_found. |
409 | default_required | Update | isDefault: false sent for the current default service. |
409 | default_service | Delete | Attempting to delete the current default service. |
422 | preview_not_available | Preview | Requested channel has no template today: whatsapp, push, totp, sna. |
Webhook endpoints
Webhook endpoints subscribe to Verify lifecycle events (verify.started, verify.succeeded, verify.failed) instead of polling. They live on the same dual-auth surface as services and can optionally be scoped to a single Verify Service.
Create a webhook
| Field | Type | Description |
|---|---|---|
| urlrequired | string | Must start with https://. Validated at write time against a public-URL check (resolves DNS, rejects private/internal targets) in addition to the schema check, so a malicious endpoint cannot slip past creation. |
| topicsrequired | array of string | At least one topic, each matching verify.* or verify.<event> (for example verify.succeeded). |
| serviceIdoptional | string | Optional vs_... id to scope this webhook to one service. Omit to receive events from every service in the workspace. An id that does not resolve returns 404 service_not_found. |
curl -X POST https://api.message.com/v1/verify/webhooks \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://acme.com/webhooks/verify",
"topics": ["verify.succeeded", "verify.failed"]
}'{
"webhook": {
"id": "uuid",
"workspaceId": "uuid",
"url": "https://acme.com/webhooks/verify",
"secret": "whsec_...",
"topics": ["verify.succeeded", "verify.failed"],
"serviceId": null,
"enabled": true,
"consecutiveFailures": 0,
"disabledReason": null,
"createdBy": "uuid",
"createdAt": "2026-07-25T18:04:11.000Z",
"updatedAt": "2026-07-25T18:04:11.000Z"
}
}The secret field is what you use to verify the X-Webhook-Signature header on incoming deliveries; see Webhooks overview for the signing scheme. It is returned on this create response and is currently also included in the list and update responses. Treat it as a credential either way: store it server-side next to your API key, and never expose it to a browser or mobile client.
List webhooks
Optional ?serviceId=vs_...narrows to one service's endpoints. Without it, every webhook in the workspace is returned.
Update a webhook
Accepts url, topics, and enabled. Changing url re-runs the public-URL check. Setting enabled: truealso clears the endpoint's failure counter and any auto-disable reason from repeated delivery failures.
Delete a webhook
List recent deliveries
Returns the endpoint's last 50 delivery attempts, newest first.
Send a test delivery
Enqueues one synthetic delivery to this endpoint with a fixed sample payload (topic verify.test), bypassing topic matching so it always reaches this endpoint regardless of its subscribed topics. Useful for confirming your receiving server and signature verification work before relying on a real event.
curl -X POST https://api.message.com/v1/verify/webhooks/{id}/test \
-H 'Authorization: Bearer mk_live_...'{ "enqueued": true }Webhook endpoint errors
| Status | Reason | Where | When |
|---|---|---|---|
400 | validation_error | Create, update, list | Body or query fails schema validation. |
400 | forbidden_url | Create, update (when url changes) | The URL resolves to a private, internal, or otherwise disallowed target. Response includes a reason field with the specific check that failed. |
404 | service_not_found | Create, list (with serviceId) | serviceId does not resolve in this workspace. |
404 | not_found | Update, delete, deliveries, test | Webhook id does not resolve in this workspace. |
Common pitfalls
- You cannot un-default a service without defaulting another. There is always exactly one default per workspace with at least one service.
- Preview only covers channels with a shipped template. Requesting a preview for
whatsapp,push,totp, orsnareturns422, matching those channels'channel_not_availablestatus on the send side. - Webhook secrets are credentials. The
secretappears in create, list, and update responses today, so any code path that logs or proxies those responses is leaking a signing key. Keep it server-side only. - A repeatedly-failing webhook can auto-disable. If deliveries stop arriving, check
enabledanddisabledReasonon the endpoint before assuming the server-side sender is broken.