Ogave SMSDevelopers

Ogave SMS Messaging API

Send SMS to 19+ countries, WhatsApp messages and voice calls with one API, with automatic fallback between channels; follow every message to the handset, and receive replies. REST + JSON, base URL https://app.ogave.co/v1. Machine-readable spec: OpenAPI 3.1 (YAML) · Postman collection + environment · SDKs & tools.

Quick start

  1. Create an account in the portal, top up, and request a sender ID (e.g. your brand).
  2. Developers → API keys → create a test_ key and a live_ key. Keys are shown once.
  3. Send your first message with the test key (nothing is sent or charged), then switch to the live key.
curl https://app.ogave.co/v1/messages \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-42-shipped" \
  -d '{"to": "+573001234567", "from": "SHOP", "text": "Your order 42 has shipped", "client_ref": "order-42"}'

Response 201 Created with the message object: status is queued, and you get encoding, segments and price right away.

Authentication

Send the key in the header: Authorization: Bearer live_…. Never put keys in URLs: a request with a key in the query string is refused (key_in_url). Keys are stored hashed; we show them once. Create as many as you need (one per system), see when each was last used and revoke any of them from the portal.

Every response has an X-Request-Id header. Send your own X-Request-Id (8-64 chars) to correlate logs.

Test mode

test_ keys run the same validation, pricing and responses as live keys, keep their data separate, and never reach an operator or touch your balance. Test messages move queued → sent → delivered within ~3 seconds and fire your webhooks (with "mode": "test"). Magic numbers force other outcomes:

ToFinal status
+15005550001undelivered
+15005550002expired
+15005550003rejected
any other valid numberdelivered

POST /v1/messages

FieldTypeDescription
to *stringE.164 (+573001234567), or a national number plus country (ISO, e.g. CO). We normalize and validate it.
from *stringAn approved sender ID (≤ 11 chars) or a number assigned to your account.
text *stringUp to 1,600 characters / 10 segments. GSM-7 (160/153 per segment) or Unicode (70/67), detected automatically. Optional when template_id is sent.
template_idstringId of one of your approved SMS templates (Templates in the portal). Without text the template text is sent as is; with text, the text must be that template with only its {placeholders} filled in. See SMS templates.
client_refstring ≤ 64Your reference; returned everywhere and filterable.
scheduled_atISO-8601Send later (≤ 30 days). Earlier than now + 30 s means send now.
validityseconds60 – 259,200 (default 172,800). Expired messages are not sent and are refunded.
callback_urlURLStatus webhooks for this message (overrides the account status URL).
flashbooleanFlash SMS (GSM class 0): shown on the screen at once and not saved in the inbox. Same price. Some phones and networks show it as a normal SMS.
shorten_urlsbooleanReplace every http(s) link in text by a short link https://go.ogave.co/xxxxxxx (27 characters). Default false. Segments and price are those of the final text. Free.
track_clicksbooleanA short link of its own per recipient, so a click is that number (message.clicked webhook, Links & clicks in the portal). Implies shorten_urls. Without it all recipients of a batch share one short link per destination.
conversion_trackingbooleanDefault false. Lets you report a conversion for this message within 7 days: see Conversion tracking.
categorystringtransactional (default) or marketing. Marketing messages follow your sending rules (frequency cap, quiet hours at the recipient). Verify / OTP messages are always transactional.

Sending rules: frequency cap and quiet hours

Set in the portal (Messaging › Sending rules; the platform default applies until you save your own) and applied to every marketing message: API (category: "marketing"), Send SMS, campaigns and multichannel campaigns. Transactional messages are never delayed or capped.

SMS templates and approval

New and edited SMS templates are reviewed by our team (pending → approved or rejected with a reason, shown in the portal; editing the text sends it back to review). If your plan has the rule only approved templates can be sent, every SMS text (/v1/messages, batch items, SMS fallback steps) must come from an approved template: send its template_id, or a text equal to an approved template where each {placeholder} is replaced by some text. Otherwise the message is refused with template_required. A pending, rejected or disabled template_id gives template_not_approved; an unknown one unknown_template. Placeholders are not filled by the API: replace them yourself in text.

Codes are 7 random characters (letters and digits), valid 90 days. The link answers 302 to your destination with its query string (and any query added by the visitor). Link previews (WhatsApp, iMessage, Telegram, Slack, Facebook, Google, Apple and other bots, HEAD and prefetch requests) are recorded as previews, never as clicks. Refused destinations (link_refused): other than http/https, localhost or private / reserved IP addresses, links with user:password@, other URL shorteners (bit.ly, tinyurl.com…) and blocked domains. Disabled or expired links answer 410. Verify codes are never shortened.

POST /v1/messages/batch

Up to 1,000 recipients per call. Top-level from, text, scheduled_at, validity, callback_url, flash, shorten_urls, track_clicks, template_id, category apply to every item; items can override them. Each recipient is validated and charged on its own; the response lists a result per index (message or error), with accepted, rejected and total_price.

curl https://app.ogave.co/v1/messages/batch \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "from": "SHOP",
    "text": "Flash sale today: 20% off",
    "messages": [
      {"to": "+573001234567"},
      {"to": "+56987654321", "text": "Oferta: 20% hoy", "client_ref": "cl-1"}
    ]
  }'

Message object

{
  "id": "msg_01m65jn6f5x9jej71dmq941vkb",
  "object": "message",
  "mode": "live",
  "to": "+56987654321",
  "from": "SHOP",
  "text": "Live API test: código 1234",
  "client_ref": "live-1",
  "status": "delivered",
  "error": null,
  "encoding": "UCS-2",
  "segments": 1,
  "price": "0.0280",
  "currency": "USD",
  "country": "CL",
  "scheduled_at": null,
  "expires_at": "2026-11-01T04:56:27Z",
  "category": "transactional",
  "delayed_by": null,
  "callback_url": null,
  "created_at": "2026-10-30T04:56:27Z",
  "submitted_at": "2026-10-30T04:56:27Z",
  "done_at": "2026-10-30T04:56:54Z"
}

Statuses

StatusMeaning
scheduledWaiting for scheduled_at (yours, or the end of the recipient's quiet hours: delayed_by). Not charged yet; can be canceled.
queuedAccepted and charged, waiting to be submitted to the operator network.
sentAccepted by the operator network; waiting for the handset receipt.
deliveredDelivered to the handset (final).
undeliveredThe operator could not deliver it; error.code has the operator error (final).
expiredValidity ran out (final). If it expired before submission, it was refunded.
rejectedRefused by the network at submission; refunded (final).
failedCould not be processed, e.g. no balance at the scheduled time (final).
canceledScheduled message canceled by you (final).

WhatsApp and voice messages add read, ringing, answered, completed, busy and no_answer: see Channel statuses.

GET /v1/messages/{id} · GET /v1/messages

List newest first, limit 1–100 (default 20), cursor pagination with starting_after=<next_cursor>. Filters: status, to, from, client_ref, created_after, created_before.

curl "https://app.ogave.co/v1/messages?status=delivered&limit=50" -H "Authorization: Bearer $API_KEY"
# next page: add &starting_after=<next_cursor>

DELETE /v1/messages/{id}

Cancels a scheduled message. Other statuses return not_cancelable.

POST /v1/emails

Send one email from your app and follow it through delivery. Use an address of one of your verified domains (GET /v1/email-identities); without from it goes from your default sender, or from the platform address with replies to your account email. Priced per email; test keys (test_…) simulate the whole timeline and are free. Scope email:send (email:read to read). Idempotency-Key works as on every POST.

FieldType
tostringRequired. An email address (or the phone number of a contact that has an email).
from, from_name, reply_tostringOptional sender identity.
subject + html and/or textstringOr template_id (an email template of yours) + variables for its {placeholders}.
attachmentsarrayUp to 10 {filename, content (base64), content_type?}, 5 MB in total; executable types (.exe, .js, .bat …) are refused. Kept only until the provider has the email.
tagsarrayUp to 10 labels (A-Z a-z 0-9 _ . : -); filter the list with ?tag=.
metadataobjectUp to 20 keys with text / number / boolean values (4 KB), returned on the email and its webhooks.
categorystringtransactional (default) or marketing (adds an unsubscribe link and List-Unsubscribe).
track_opens, track_clicksbooleanDefault true.
client_ref, callback_urlstringYour reference; a webhook URL for this email only.
curl https://app.ogave.co/v1/emails \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: receipt-1001" \
  -d '{"to": "ana@example.com", "from": "hola@yourshop.com", "subject": "Your receipt #1001",
       "html": "<p>Thanks for your order</p>", "tags": ["receipt"], "metadata": {"order_id": "1001"},
       "attachments": [{"filename": "receipt-1001.pdf", "content": "JVBERi0xLjQK…"}]}'

Email object and timeline

GET /v1/emails/{id} and GET /v1/emails (newest first; filters status, to, from, tag, client_ref, created_after, created_before, limit, starting_after) return the email with events: every step with its time, in the order they happened.

EventMeaning
acceptedStored and charged.
sentHanded to the mail provider.
deliveredThe receiving mail server accepted it.
deferredA temporary failure; the provider keeps trying (detail says why).
bouncedPermanent failure: the address goes to your suppression list (the email stays charged).
failedRefused before sending (e.g. by the provider).
complainedMarked as spam: the address goes to your suppression list.
opened, clickedOpened (tracking pixel) / a link clicked (url). Counted every time; the first 20 of each are in the timeline.
unsubscribedUnsubscribed (marketing emails): on your suppression list.

status is where the email is: accepted, sent, deferred, delivered, bounced, failed, complained or canceled; opened, clicked and unsubscribed are flags with opens, clicks, opened_at, clicked_at. The same email is also in /v1/messages (channel: email) and in the portal under Channels › Email › Log, with its timeline.

Email webhooks

Each event of an email sent with /v1/emails is posted to its callback_url or your status URL, signed like every webhook (Webhook-Signature): email.sent, email.delivered, email.deferred, email.bounced, email.failed, email.complained, email.opened, email.clicked, email.unsubscribed. data is the email object plus event {type, at, detail?, url?}. Emails sent with /v1/messages keep their message.* webhooks.

GET /v1/email-identities

What from may be: your sending domains (status pending / verified and the DNS checks), the sender addresses on them (default, usable = its domain is verified) and the platform address. Add domains and senders in Channels › Email.

Delivery events from the mail provider

The platform reads delivery events from its mail provider (Amazon SES through SNS, Mailgun webhooks, or an SMTP relay). A relay posts them to the webhook URL shown in the admin panel, signed with its webhook secret:

POST /webhooks/email/mailer/<account>
X-Signature: sha256=<hex HMAC-SHA256 of the raw body with the webhook secret>
{"events": [{"message_id": "<Message-ID of the email>", "event": "delivered", "timestamp": 1793750400, "detail": "250 2.0.0 OK"}]}
# event: delivered | deferred | bounced | complained | opened | clicked | unsubscribed
HTTPcodeWhen
422invalid_attachments, too_many_attachments, attachments_too_largeAttachments are not a list of {filename, content}, more than 10, or over the size limit.
422blocked_attachment, invalid_filename, invalid_attachment_contentAn executable file type, a file name with slashes, or content that is not base64.
422invalid_tags, invalid_metadataTags or metadata outside the limits above.

Balance and pricing

GET /v1/balance → {"balance": "48.8390", "currency": "USD", "unlimited": false}
GET /v1/pricing?to=%2B573001234567 → country and price per segment for that number.
GET /v1/pricing/countries → your whole price list.

Webhooks

Set your URLs with PUT /v1/webhooks (status_url, inbound_url) or in the portal. Events: message.sent, message.delivered, message.undelivered, message.expired, message.rejected, message.failed, message.inbound (replies to your numbers), message.clicked (a person opened a short link of the message: the message object plus url, short_url, clicked_at, device, os, browser; one event per click, link previews excluded), and the Verify events. WhatsApp and voice add more status events and WhatsApp inbound messages. Messages sent from the portal (campaigns, quick send, files) also produce message.* status webhooks, with "source": "portal" and the campaign_id. The body is {"id","type","mode","created_at","data"} with the message object in data.

POST /your/status/url HTTP/1.1
Content-Type: application/json
Webhook-Event: message.delivered
Webhook-Delivery: whd_01m65jnr2e3x4d5f6g7h8j9k0m
Webhook-Signature: t=1793336214,v1=5f2b0c0e9d6a…

{"id":"evt_01m65jnr…","type":"message.delivered","mode":"live","created_at":"2026-10-30T04:56:54Z","data":{ …message object… }}

Verify signatures

Webhook-Signature: t=<unix time>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>. Compute it over the raw body, compare in constant time, and reject timestamps older than 5 minutes. (Receivers built before November 2026 may read the same values under the older header names, which are still sent.) Get or rotate the secret in the portal or with POST /v1/webhooks/rotate-secret (shown once).

import crypto from "node:crypto";

// Use the RAW request body (e.g. express.raw({ type: "application/json" })).
export function verifyWebhookSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Retries and delivery log

Any 2xx response within 5 seconds counts as delivered. Otherwise we retry after 1 min, 5 min, 30 min, 2 h and 12 h, then mark the delivery failed. Redirects are not followed. Every attempt is logged (status code, first 1 KB of your answer): GET /v1/webhooks/deliveries, and resend any of them with POST /v1/webhooks/deliveries/{id}/retry (or from the portal). Use data.id + type to de-duplicate: a webhook may arrive more than once.

Idempotency

Send Idempotency-Key: <your unique id> (≤ 64 chars) on any POST request (messages, batch, conversions, Verify, contacts, reports, hooks…). Retrying with the same key and the same body within 24 hours returns the original response with Idempotent-Replayed: true and never acts twice, including when the first answer was an error. The body is compared as JSON (key order and spacing do not matter). The same key with a different body is refused with 409 idempotency_conflict; a request still running with the key gets 409 idempotency_in_progress (retry after a second). Keys are scoped to your account and key mode. The official SDKs add a key to every POST automatically, so their retries are safe; client_ref stays your own reference and is not used for de-duplication.

Rate limits

Each key allows 50 requests per second by default (ask us for more). Above that you get 429 rate_limited with Retry-After; every response has X-RateLimit-Limit and X-RateLimit-Remaining. A batch call counts as one request.

Errors

All errors have the same shape and a request_id. param names the offending field when there is one.

HTTP/1.1 402 Payment Required
X-Request-Id: req_01m65jmsn5wwdhwfejxhx2mvy0

{
  "error": {
    "code": "insufficient_balance",
    "message": "Your balance does not cover this message. Top up in the portal.",
    "doc_url": "https://app.ogave.co/docs/api#errors-insufficient_balance",
    "request_id": "req_01m65jmsn5wwdhwfejxhx2mvy0"
  }
}
HTTPcodeWhat to do
400invalid_jsonThe body is not valid JSON.
422conversion_tracking_offThe message was sent without conversion_tracking: true; send the next ones with it.
409conversion_window_closedConversions can be reported within 7 days of sending.
404no_tracked_messageNo tracked or campaign message to this number in the last 7 days.
415unsupported_media_typeSend JSON with Content-Type: application/json.
401missing_api_keyNo Authorization: Bearer header.
401invalid_api_keyThe key does not exist.
401revoked_api_keyThe key was revoked in the portal.
401key_in_urlA key was sent in the query string. Revoke it and send keys only in the header.
402insufficient_balanceTop up the account balance.
403account_disabledThe account is disabled.
403sender_not_approvedRequest the sender ID in the portal and wait for approval.
404not_foundUnknown id or endpoint (ids are scoped to the key mode).
409not_cancelableOnly scheduled messages can be canceled.
409idempotency_in_progressA request with the same Idempotency-Key is still running; retry shortly.
422validation_failedA parameter is missing or invalid; see param.
422parameter_not_allowedA parameter that tries to pick a route (sending_server, gateway, route, server...) was sent. The platform chooses the route for every message; remove it.
422invalid_numberThe destination is not a valid phone number.
422unsupported_countryThe destination country is not enabled for the account.
422sender_blockedThe sender (from) is blocked on this platform by the administrator (Compliance).
422spam_contentThe text contains words the platform does not allow (only when the administrator turned this check on for the API).
422blocked_numberThe number opted out or is blocked.
422frequency_cappedMarketing message over the frequency cap of the account for this number (per day or week in the recipient's time zone). Not stored, not charged. See Sending rules.
409idempotency_conflictThe Idempotency-Key was already used with a different body: use a new key for a new request.
422invalid_channelUnknown channel (in channel or in a fallback step). Use sms, whatsapp, voice or auto.
422channel_not_availableThe channel exists but is not available on the API yet.
422no_channel_accountNo active WhatsApp or voice number is connected, or account is unknown. Connect one in the portal.
422not_sellableThe channel is not enabled for the destination country on your account.
422opted_outThe contact opted out of this channel (e.g. replied STOP on WhatsApp), or POST /v1/contacts with a blacklisted number.
422limit_reachedPOST /v1/hooks: the account already has the maximum number of hook subscriptions. Delete unused ones.
422unknown_templateNo template with that name (and language) on the WhatsApp number (sync templates in the portal), or no SMS template with that template_id on your account.
422template_not_approvedThe template is pending, rejected or paused (WhatsApp) or pending review, rejected or disabled (SMS); only approved templates can be sent.
422template_requiredYour plan only sends text from an approved SMS template: send template_id, or text equal to an approved template with only its {placeholders} filled in.
422missing_variablestemplate.variables has fewer values than the template needs.
422session_closedWhatsApp text or media outside the 24 h window. Send an approved template.
422invalid_mediamedia.type must be image, document, video or audio, and media.url an https URL.
422invalid_audioaudio_url must be an https URL.
422unknown_flowThe ivr flow id does not exist on your account.
422missing_contentA voice message needs tts, audio_url or ivr.
422missing_textThe text is required (WhatsApp session message, or the channel needs text).
422invalid_durationfallback[].after must look like 90s, 5m or 1h, between 30 seconds and 24 hours.
403premium_numberThe number is in a premium / high-cost range.
403new_country_limitA new country for your account reached its daily limit: opt it in on the Fraud protection page.
429country_throttledVerify traffic to this country is slowed down after an anomaly; retry later.
403country_blockedVerify traffic to this country is blocked for this app after an anomaly (unblock in the portal).
422no_otp_templateVerify on WhatsApp needs an approved authentication template on your WhatsApp number.
429rate_limitedToo many requests for this key; wait Retry-After seconds.
500internal_errorOur fault. Retry with the same Idempotency-Key; contact support with the request_id.

Billing

Price per segment for the destination country, from your plan (GET /v1/pricing/countries). Charged when the message is queued (immediately, or at scheduled_at). Refunded if the network rejects it at submission, if it expires before submission, or if you cancel a scheduled message. Delivery receipts don't change the charge. Test keys are never charged.

Reports

Exports run in the background so they never time out: POST /v1/reports with type (messages = one row per message, summary, transactions), date_from, date_to (up to 12 months per export), timezone, optional filters (status, country, network MCC-MNC, sender, campaign_id, source, api_key_id, account_id, destination, text, error_code), columns, format (csv or xlsx), delimiter, gzip. It answers 202 with id (rep_…). Poll GET /v1/reports/{id} until status is ready and download download_url (signed, no API key needed, valid 7 days). POST /v1/reports/{id}/cancel stops it. CSV files are UTF-8 with BOM; cells starting with = + - @ are prefixed with '. Verify codes are always masked. Use GET /v1/messages for live lookups of single messages.

Data retention. Message details are kept for 3 months; up to 12 months on request. messages exports and GET /v1/messages cover the last 3 months; older periods are archived and can be requested in the portal (Reports › Archived messages, usually a few minutes). summary exports, dashboards and statements work for any period: daily summaries are kept forever.

curl -X POST https://app.ogave.co/v1/reports -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"type":"messages","date_from":"2026-10-01","date_to":"2026-10-31","timezone":"America/Bogota","filters":{"country":["CO"],"status":["undelivered"]}}'

Conversion tracking

A conversion means the recipient did what the message asked (bought, signed in, booked). Send with "conversion_tracking": true and tell us when it happens, up to 7 days after sending. Conversion rates per country, network, sender ID, campaign, template and day are in Reports › Conversions (and CSV). An approved Verify code counts automatically.

curl -X POST https://app.ogave.co/v1/messages -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"to":"+573001234567","from":"SHOP","text":"Your cart is waiting: 10% off today","conversion_tracking":true}'

curl -X POST https://app.ogave.co/v1/messages/msg_01jb…/conversion -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"event":"purchase","value":49.90,"currency":"USD"}'
FieldWhat
converted_atOptional, ISO 8601; default now. Between the sending time and now.
value + currencyOptional: amount of the conversion and ISO 4217 currency (required with value).
eventOptional, up to 40 characters (letters, digits, _ . : -), e.g. purchase, signup.

Answers 201 with the conversion object; repeating the call for the same message answers 200 with the same conversion (the first one stays).

{"id":"conv_01jb…","object":"conversion","mode":"live","source":"api","message_id":"msg_01jb…","campaign_id":null,
 "event":"purchase","value":"49.90","currency":"USD","sent_at":"2026-11-03T10:00:00Z","converted_at":"2026-11-03T10:42:00Z","seconds_to_convert":2520}

By phone number (campaigns)

When you do not keep the message id, or the message went out in a portal campaign: POST /v1/conversions {"to":"+573001234567","event":"purchase"} — it goes to the latest tracked message or campaign message sent to that number in the last 7 days (404 no_tracked_message when there is none). GET /v1/conversions lists conversions (limit, starting_after, message_id).

Webhook: event message.converted to your status URL (and to Zapier / Make hooks) with the conversion object.

Versioning

The version is in the path. We add fields, events and optional parameters without a new version, so ignore unknown fields. Breaking changes go to /v2, and /v1 stays available for at least 12 months after that.

Channels: WhatsApp, RCS, Telegram, Viber, voice and email

The same POST /v1/messages sends SMS, WhatsApp, RCS, Telegram and Viber messages, voice calls and emails. Add channel and the content for that channel; without channel everything works exactly as described above for SMS. Every attempt is a normal message (msg_…) with its own status, price, webhooks and report row, so GET /v1/messages, client_ref, callback_url and idempotency work the same on every channel.

curl https://app.ogave.co/v1/messages \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-A123-shipped" \
  -d '{
    "channel": "whatsapp",
    "to": "+573001234567",
    "template": {"name": "order_shipped", "language": "es", "variables": ["Ana", "A123"]},
    "client_ref": "order-A123",
    "fallback": [
      {"channel": "sms", "after": "5m", "text": "Ana, tu pedido A123 fue enviado"},
      {"channel": "voice", "after": "10m", "tts": {"text": "Hola Ana, su pedido fue enviado", "language": "es-MX"}}
    ]
  }'

channel

ValueSends
sms (default)An SMS, with the fields of Send a message.
whatsappA WhatsApp message from your WhatsApp number: an approved template, or session text / media.
rcsAn RCS message from your RCS agent: text (≤ 3072) with optional suggestions, or media. See RCS, Telegram and Viber.
telegramA Telegram message from your bot to the chat connected to to: text (≤ 4096) or media.
viberA Viber message from your bot to the chat connected to to: text (≤ 7000) or media.
voiceA phone call from your voice number: tts, audio_url or an ivr flow.
emailAn email from one of your verified domains (or the platform address): subject + html / text, or an email_template. See Email.
autoWe pick: WhatsApp when your account has an active WhatsApp number, the contact has not opted out of WhatsApp, and either the request has a template or the contact wrote to you on WhatsApp in the last 24 h (open session). Otherwise SMS (so include text). The response's channel tells you which one was used.

Fields shared by all channels: to, client_ref, callback_url, fallback, and account: the id of one of your connected WhatsApp / voice numbers, RCS agents or bots (portal → Channels). Without it we use your first active number of that channel. On WhatsApp and voice the sender is that number, so from is not used; scheduled_at, validity and flash apply to SMS only.

WhatsApp

FieldTypeDescription
templateobjectname * (an approved template of the WhatsApp number), language (e.g. es; optional when the name is unique), variables (array of strings, ≤ 1,024 chars each) filling {{1}}, … in order. At least as many variables as the template has are required (missing_variables). Templates can be sent at any time.
textstringSession message, up to 4,096 characters.
mediaobjectSession message with a file: type * (image, document, video, audio), url * (https), caption (≤ 1,024), filename (≤ 120, shown for documents).

24-hour window. Free text and media can only be sent within 24 hours of the contact's last WhatsApp message to you; outside it the request fails with 422 session_closed (send a template instead). Template, session and opt-out checks run before anything is charged.
Opt-out. A contact who replies STOP, BAJA, UNSUBSCRIBE, CANCELAR, PARAR on WhatsApp is opted out of WhatsApp: later sends to that number on WhatsApp fail with opted_out and auto uses SMS.

# Only within 24 h of the customer's last WhatsApp message (otherwise 422 session_closed)
curl https://app.ogave.co/v1/messages -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "whatsapp", "to": "+573001234567", "text": "It arrives tomorrow before noon."}'

curl https://app.ogave.co/v1/messages -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "whatsapp", "to": "+573001234567",
       "media": {"type": "document", "url": "https://example.com/invoices/A123.pdf", "caption": "Your invoice", "filename": "A123.pdf"}}'

Voice

FieldTypeDescription
ttsobject or stringText to speech: text * (≤ 1,000 chars), language (e.g. es-MX, en-US; default es-MX), voice (female default, or male). A plain string is the text. Without tts, text is read.
audio_urlURLAn https link to a recording to play.
ivrstringThe id of an IVR flow from the portal (menus, key presses, transfers). Pressed keys appear in voice.digits.

Give one of them (checked in the order ivr, audio_url, tts); none → missing_content.

# Text to speech
curl https://app.ogave.co/v1/messages -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "voice", "to": "+573001234567", "tts": {"text": "Hola, su pedido fue enviado", "language": "es-MX", "voice": "female"}}'

# Play a recording
curl https://app.ogave.co/v1/messages -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "voice", "to": "+573001234567", "audio_url": "https://example.com/audio/reminder.mp3"}'

# Run an IVR flow built in the portal, from a specific voice number
curl https://app.ogave.co/v1/messages -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "voice", "to": "+573001234567", "ivr": "01jb0c4q8m2n3p4r5s6t7v8w9x", "account": "01jb0bz1k2m3n4p5q6r7s8t9v0"}'

RCS, Telegram and Viber

Every channel is addressed by phone number (to in E.164). Content:

FieldTypeDescription
textstringRequired unless media. RCS ≤ 3072, Telegram ≤ 4096, Viber ≤ 7000 characters.
mediaobject{"url": "https://…", "caption": "…"} — an image: RCS rich card, Telegram photo, Viber picture. https only (invalid_media).
suggestionsarrayRCS only: up to 11 suggested replies of ≤ 25 characters (invalid_suggestions). The contact's tap arrives as an inbound message.
curl -X POST https://app.ogave.co/v1/messages \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "rcs", "to": "+573001234567", "text": "Your order A123 has shipped", "suggestions": ["Track it", "Talk to us"],
       "fallback": [{"channel": "sms", "after": "2m"}]}'
HTTPCodeMeaning
422not_linkedTelegram / Viber: this number has not connected a chat with your bot yet. Send them your bot link.
422invalid_suggestionsRCS: more than 11 suggestions or one longer than 25 characters.

Email

Verify your sending domain first (portal → Channels › Email → Sending domains: publish the ownership TXT and the DKIM key; SPF and DMARC are recommended). Emails are DKIM-signed with your domain's key. Each request sends one email to one recipient and is priced per email.

FieldTypeDescription
to *stringAn email address, or the phone number (E.164) of one of your contacts that has an email (contact profile or the contact's EMAIL field). Unknown → no_email.
fromemailAny address of one of your verified domains, e.g. news@mail.yourshop.com. Not verified → from_not_verified. When it is one of your sender identities (Channels › Email → Sender identities) its name and reply-to are used. Without from the email goes as your default identity, or, if you have none, from the platform address with Reply-To your account email. Gmail / Outlook addresses can only be a reply-to.
from_namestringDisplay name, ≤ 100 chars (default: the identity's name, else your company name).
reply_toemailReply-To address.
subjectstring≤ 255 chars. Required unless email_template is given (missing_subject).
htmlstringHTML body (≤ 512 KB). Links are rewritten to count clicks; an open pixel is added.
textstringPlain-text body. With only text, a simple HTML version is made from it. One of html, text or email_template is required (missing_content).
email_templatestringThe id of one of your email templates (Channels › Email → Templates); it gives subject, HTML, text and category. Unknown → unknown_template.
variablesobjectValues for {placeholders} in subject, HTML and text, e.g. {"first_name": "Ana"} fills {first_name} (keys are case-insensitive; values are HTML-escaped in the HTML). Journeys and multichannel campaigns fill {first_name}, {last_name} and every contact field (its tag in lowercase) themselves.
categorystringtransactional (default) or marketing: marketing emails get an unsubscribe link and the one-click List-Unsubscribe header.
track_opens, track_clicksbooleanDefault true.
curl https://app.ogave.co/v1/messages \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "email", "to": "ana@example.com", "from": "news@mail.yourshop.com", "from_name": "Your Shop",
       "email_template": "01jexample0template0id00000", "variables": {"first_name": "Ana"}, "category": "marketing"}'

Email statuses. queued → sent (accepted by the mail server) → delivered → read (= opened; a click also counts as opened). failed = bounced: the address is added to your suppression list. The message object has an email object: subject, category, template, from_name, reply_to, opens, clicks, opened_at, clicked_at.

Email webhooks. Besides message.sent, message.delivered, message.read (opened) and message.failed (bounced): message.clicked (with the url clicked), message.complained (marked as spam) and message.unsubscribed. Complaints and unsubscribes add the address to your suppression list and do not change the message status.

Suppression list. Bounced, complained and unsubscribed addresses (and the ones you add in the portal) are never emailed again: sending to them fails with 422 suppressed and is not charged. Remove an address in the portal to email it again.
Price. One price per email (email, any country), charged when it is sent; bounces are not refunded. Test keys are not charged.

HTTPcodeWhen
422suppressedThe address is on your suppression list (bounce, complaint, unsubscribe or added by you).
422from_not_verifiedfrom is not an address of one of your verified domains.
422no_emailto is a phone number and no email is known for that contact.
422invalid_email / invalid_from / invalid_reply_toNot a valid email address.
422missing_subject / missing_content / too_largeNo subject, no body, or HTML over 512 KB.
422no_channel_accountEmail sending is not enabled on the platform yet.

Fallback chains

fallback is a list of up to 3 more steps (extra steps are ignored). Each step has channel (sms, whatsapp, voice, email), after and its own content (text, template, media, tts, audio_url, ivr, account, from; for email subject, html, email_template, variables, category). Between email and phone channels the contact's email / phone number is looked up. It works with any first channel, SMS included.

POST /v1/messages/quote

Same body as POST /v1/messages. Nothing is sent or charged, and the content is validated as for a real send (template, session, opt-out, account). Answers 200 with the price of the first step and of each fallback step; max_total is the worst case, when every step is sent. Voice steps show the price of the first 60-second block. A step's price is null when that channel is not sold for the destination. Quotes use live prices, also with a test key.

curl https://app.ogave.co/v1/messages/quote -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"channel": "whatsapp", "to": "+573001234567",
       "template": {"name": "order_shipped", "variables": ["Ana", "A123"]},
       "fallback": [{"channel": "voice", "after": "10m", "tts": "Hola Ana, su pedido fue enviado"}]}'

HTTP/1.1 200 OK
{
  "object": "quote",
  "currency": "USD",
  "max_total": "0.130000",
  "steps": [
    {"channel": "whatsapp", "price": "0.0100"},
    {"channel": "voice", "price": "0.1200", "after_seconds": 600}
  ]
}

For a plain SMS (channel sms or auto resolving to SMS) the quote has only the SMS step and no max_total; fallback steps of an SMS-first chain are not priced. Quote a WhatsApp or voice first step to see the whole chain.

Message object: channel fields

Every message has channel (sms, whatsapp, voice). WhatsApp and voice messages also have one of these objects, and messages in a chain have fallback. On these channels encoding is UTF-8 and segments is 1.

FieldDescription
whatsapptype (template, text, media, otp for Verify codes), template, language, category (marketing, utility, authentication, or service for session messages), media_url. Empty fields are left out.
voicetype (tts, audio, ivr, otp), duration (seconds, once the call ends), billable_seconds, digits (keys pressed in an IVR, comma separated).
fallbackprevious, next, pending: see Fallback chains.
{
  "id": "msg_01m6a2v7k3q9x8r5t4y2w1z0b9",
  "object": "message",
  "mode": "live",
  "channel": "whatsapp",
  "to": "+573001234567",
  "from": "+15550001111",
  "text": "Hi Ana, your order A123 has shipped.",
  "client_ref": "order-A123",
  "status": "sent",
  "error": null,
  "encoding": "UTF-8",
  "segments": 1,
  "flash": false,
  "price": "0.0100",
  "currency": "USD",
  "country": "CO",
  "scheduled_at": null,
  "expires_at": null,
  "callback_url": null,
  "created_at": "2026-10-30T15:02:11Z",
  "submitted_at": "2026-10-30T15:02:12Z",
  "done_at": null,
  "whatsapp": {"type": "template", "template": "order_shipped", "language": "es", "category": "utility"},
  "fallback": {"pending": [{"channel": "sms", "after_seconds": 300}, {"channel": "voice", "after_seconds": 600}]}
}

// A voice attempt that was sent as step 2 of a chain:
{
  "id": "msg_01m6a3c0d4e5f6g7h8j9k0m1n2",
  "channel": "voice",
  "status": "completed",
  "price": "0.1200",
  "voice": {"type": "tts", "duration": 37, "billable_seconds": 60, "digits": null},
  "fallback": {"previous": "msg_01m6a2z9…"},
  "…": "the other message fields"
}

Channel statuses

Besides the SMS statuses (queued, sent, delivered, failed, rejected, canceled also apply), WhatsApp and voice messages use:

StatusChannelMeaning
deliveredWhatsAppDelivered to the phone. Not final on WhatsApp: it can still become read.
readWhatsAppThe contact opened it (final). Only reported when the contact has read receipts on.
failedWhatsApp, voiceNot delivered, e.g. the number is not on WhatsApp or the call could not be placed (final, refunded).
ringingvoiceThe phone is ringing.
answeredvoiceThe call was picked up and is in progress.
completedvoiceThe call was answered and has ended; voice.duration is set and the price is final (final).
busyvoiceThe line was busy (final, refunded).
no_answervoiceNobody picked up (final, refunded).

Statuses only move forward: a late sent never overwrites delivered.

Channel webhooks

Each status change sends message.<status> to the message's callback_url or your status URL, with the message object (including channel, whatsapp / voice and fallback) in data. New events: message.read, message.ringing, message.answered, message.completed, message.busy, message.no_answer, plus message.sent, message.delivered, message.failed on these channels. They are signed and retried like every webhook.
WhatsApp messages from your contacts arrive as message.inbound at your inbound URL with channel: "whatsapp", from, to (your WhatsApp number), text, media (or null) and received_at. SMS replies keep the shape described in Webhooks (no channel field: treat a missing channel as sms).

POST /your/inbound/url HTTP/1.1
Content-Type: application/json
Webhook-Event: message.inbound

{"id": "evt_01m6a4…", "type": "message.inbound", "mode": "live", "created_at": "2026-10-30T15:20:41Z",
 "data": {"id": "in_8812", "channel": "whatsapp", "from": "+573001234567", "to": "+15550001111",
          "text": "Where is my order?", "media": null, "received_at": "2026-10-30T15:20:41Z"}}

Channel pricing

POST /v1/journeys/{id}/runs

Starts a run of one of your active journeys (built in the portal → Journeys; {id} is the journey id shown there) for the number to. Body: to * (E.164, e.g. +573001234567) and data: an object of up to 50 key/values (keys: letters, numbers and _, up to 40 chars; values: text or numbers up to 500 chars). The values are available to the journey's steps as variables, e.g. {name} in a step's text. A number has at most one run in progress per journey: starting it again while it runs returns that run. Journeys send real messages, so a live_ key is required.

curl https://app.ogave.co/v1/journeys/01jb0d7e8f9g0h1j2k3m4n5p6q/runs \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"to": "+573001234567", "data": {"name": "Ana", "order": "A123"}}'

HTTP/1.1 201 Created
{"id": 1842, "journey": "01jb0d7e8f9g0h1j2k3m4n5p6q", "phone": "+573001234567", "state": "waiting", "step": "welcome",
 "created_at": "2026-10-30T15:30:02+00:00"}
HTTPcodeWhen
201The run: id, journey, phone, state (waiting while it runs or waits, done, failed), step (current step id), created_at.
409journey_not_activeThe journey is a draft or paused. Activate it in the portal.
403live_key_requiredCalled with a test_ key.
404not_foundUnknown journey id.
422validation_failed / invalid_numberdata or to is invalid (see param).

Messages sent by the run are normal messages: they appear in GET /v1/messages and fire the usual webhooks.

Number masking for calls

Connect two people by phone without showing their numbers — a customer and a driver, a buyer and a seller. Create a session with both numbers; we give you a proxy_number (one of your voice numbers). Either side calls the proxy number and is connected to the other side; both only ever see the proxy number. Calls from any other number, and calls after the session closes or expires, are never connected (the caller hears a short message). One proxy number serves many sessions at the same time: a person is never in two open sessions on the same proxy, so we always know whom to connect.

POST /v1/masking/sessions

FieldTypeDescription
party_a, party_bstringRequired. The two phone numbers (E.164), different.
ttlintegerSeconds until the session expires: 300 to 2592000 (30 days). Default 21600 (6 h).
numberstringUse this proxy number (one of your active voice numbers). Default: the first free one.
label, client_refstringYour own references (≤ 100 / 64 characters).
curl -X POST https://app.ogave.co/v1/masking/sessions \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"party_a": "+573001234567", "party_b": "+573109876543", "ttl": 7200, "client_ref": "order-A123"}'

201 Created
{"object": "masking_session", "id": "mks_01m6…", "mode": "live", "status": "open", "proxy_number": "+15005550006",
 "party_a": "+573001234567", "party_b": "+573109876543", "label": null, "client_ref": "order-A123", "calls_count": 0,
 "expires_at": "2026-10-31T14:00:00Z", "closed_at": null, "created_at": "2026-10-31T12:00:00Z"}

Send each side the proxy_number (e.g. "Call your driver at +1 500 555 0006").

Get, list, close

Billing, webhooks, providers

HTTPCodeMeaning
409no_proxy_availableEvery proxy number you can use already has an open session with one of these numbers. Close one or add a voice number.
422same_partyparty_a and party_b are the same number.
422invalid_ttlttl out of range.
422unknown_numbernumber is not one of your active voice numbers with masking (Twilio or test).
422live_numbersimulate-call on a live session with a real number.

Integrations: REST hooks and contacts

For Zapier, Make and your own automations. Full guide, event payloads and the Shopify, WooCommerce, HubSpot and Google Sheets connections: Integrations docs.

REST hooks

POST/v1/hooksevent (message.delivered, message.clicked, message.inbound, contact.opted_out), target_url, optional platform (zapier | make | other) → 201 hook
GET/v1/hooksYour subscriptions in the mode of the key
DELETE/v1/hooks/{id}Unsubscribe
GET/v1/hooks/samples/{event}Your last 3 real events of that type, or one example

Events are delivered like webhooks (same envelope, Webhook-Signature, retries and delivery log), always as a JSON POST. A hook only gets events of its key's mode (test keys: test events). Answer 410 Gone to delete the hook. Up to 50 hooks per account.

Contacts

GET · POST/v1/contact-listsList your contact lists · create one (name)
POST/v1/contactsAdd or update by phone inside list_id: phone, country, first_name, last_name, email, company, fields (field tag → value). 201 created, 200 updated (created says which).
GET/v1/contacts?list_id=&phone=&limit=Search (up to 100)
GET · PATCH · DELETE/v1/contacts/{id}One contact

Errors: invalid_number (not a valid number; national numbers need country), opted_out (the number is on your blacklist), validation_failed with param fields.<TAG> (unknown field or a value that does not fit its type), not_found (unknown list or contact), limit_reached (too many hooks). A contact that unsubscribed is updated but never re-subscribed.

OTP Verify API

Send one-time codes and check them with two calls. We generate the code, store only a keyed hash of it, enforce expiry, attempts and resend rules, and block SMS-pumping before it costs you money.

curl https://app.ogave.co/v1/verify \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"to": "+573001234567", "app_id": "app_01m65mc13skre3jty1rtz8jtqy", "locale": "es", "ip": "203.0.113.7"}'

# then, with the code the user typed:
curl https://app.ogave.co/v1/verify/ver_01m65mc32mhad5g95nbspkpdr4/check \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"code": "482913"}'

POST /v1/verify

FieldDescription
to *Phone number (E.164, or national + country); with channel: email, the email address.
app_idVerify app to use (settings below). Default: your first app; a "Default" app is created if you have none.
channelsms (default), whatsapp (sent with an approved authentication template of your WhatsApp number, otherwise no_otp_template), voice (a call that reads the code aloud), email (the code by email to to: Verify by email) or auto (SMS first, then the code by email if it is not verified in time; give email).
channel_order1–3 different channels tried in order, e.g. ["whatsapp", "sms", "voice"]. The first one is sent now; each next one is sent if the previous code was not delivered within fallback_after (or failed). When the verification is closed (approved, canceled…), the remaining channels are not sent. Overrides channel. Add email as the last item to use the email fallback for this request even when the app has it switched off.
emailYour end user's email address, for the email fallback. Optional.
fallback_afterSeconds to wait before the next channel of channel_order: 30–600 (default 60).
code_length4–10 (default from the app, 6).
code_typenumeric or alphanumeric (no look-alike characters 0/O/1/I/L).
expiry_seconds60–3,600 (default 600).
templateYour own text with {code} (required), {app_name}, {minutes}. Otherwise the built-in text for locale.
localeen, es, pt, fr, de, it (default from the app). See GET /v1/verify/templates.
sender_idAn approved sender ID (default from the app).
ipYour end user's IP address. Optional but recommended: enables per-IP fraud limits.
device_idA stable fingerprint of your end user's device (≤ 64 chars). Optional: enables per-device fraud limits.

Returns 201 with the verification: id, status: "pending", expires_at, attempts_remaining, price. Starting a new verification for the same number and app cancels the pending one, so only the newest code works.

Several channels, WhatsApp first:

curl https://app.ogave.co/v1/verify -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"to": "+573001234567", "app_id": "app_01m65mc13skre3jty1rtz8jtqy",
       "channel_order": ["whatsapp", "sms", "voice"], "fallback_after": 45}'

POST /v1/verify/{id}/check

Body {"code": "482913"}. Always 200 with a result:

resultMeaning
approvedCorrect code. The verification is closed.
invalidWrong code; attempts_remaining tells how many tries are left.
expiredThe code expired. Start a new verification.
max_attemptsToo many wrong codes. The verification is closed.

Checking a closed verification returns 409 verification_closed. Codes are compared in constant time. An approved verification has verified_channel: the channel whose code was entered (sms, whatsapp, voice or email).

Verify by email

With channel: email the code goes by email to to — same app limits, expiry, attempts, resends, pricing model and fraud protection (velocity per address, per email domain, IP, device and account; addresses are stored hashed in the send log). The email is the app's own (email_from = an address of a verified domain of yours, email_subject, email_html with {code}, {app}, {minutes}; PATCH /v1/verify/apps/{id} or Developers › Verify) or a standard one in the verification's locale. In the response to is masked (a***@example.com) and country is null. A suppressed address (bounce, complaint, unsubscribe) is refused at once with 422 suppressed, nothing charged.

Email fallback

Switch on email_fallback in the Verify app and send email with POST /v1/verify. If the code is not verified within email_fallback_after seconds (app setting, 30–600, default 60), we also send a code by email, from the platform's email channel, in the verification's locale. If the SMS fails before that, the email goes out at once instead of the verification failing. The email has its own code; both codes work until the verification closes, and verified_channel tells which one was used. The response shows email_fallback (masked address, due_at, sent_at). Once closed, the address is kept masked and the email copy of the code is redacted. The order is always SMS (or your channel_order) first, email last. Voice as a fallback after email is not available yet.

Resend, cancel, get, list

POST /v1/verify/{id}/resend sends a new code (the previous one stops working) and restarts the expiry. It needs the app's cooldown to have passed (429 resend_cooldown with Retry-After) and allows up to the app's max_resends (429 max_resends). Failed attempts are not reset by a resend.
POST /v1/verify/{id}/cancel · GET /v1/verify/{id} · GET /v1/verify?status=approved&to=…&limit=…&starting_after=…

Verify apps

One app per product or brand: name (used as {app_name}), code_length, code_type, expiry_seconds, max_attempts (1–10), resend_cooldown_seconds, max_resends, sender_id, template, default_locale, allowed_countries / denied_countries (ISO codes; an allow list wins), test_numbers, fraud_limits, flash (send codes as Flash SMS), email_fallback and email_fallback_after. Manage them in the portal or with GET/POST /v1/verify/apps, GET/PATCH /v1/verify/apps/{id}.

Fraud protection (SMS pumping)

Before any code is sent we check, in order: the app's country rules, active blocks, then velocity (sliding windows, counted separately for live and test keys):

ScopeDefault limitError
number3 per 1 min, 5 per 10 min, 10 per 24 h velocity_number (429)
device5 per 10 min, 20 per 24 h velocity_device (429)
ip20 per 10 min velocity_ip (429)
prefix30 per 10 min velocity_prefix (429)
country1000 per 1 h velocity_country (429)
account600 per 1 min velocity_account (429)

The prefix is the number without its last 4 digits. Burst detection: when one prefix gets 20+ codes within 5 minutes and fewer than 10% of them are approved, the prefix is blocked for 60 minutes (403 prefix_blocked), because real users approve their codes and pumping traffic doesn't. Other errors: country_not_allowed, number_blocked, ip_blocked. Limits can be tuned per app (fraud_limits).

Testing

With a test_ key nothing is sent or charged and the code is always 123456 (the first n digits of 1234567890 for longer codes). For app-store reviewers and QA on live keys, add test_numbers to the app: those numbers get your fixed code, never receive an SMS, are never charged and skip fraud limits.

Okta and Auth0

Okta and Auth0 can send their own one-time codes through your Verify app (sender, template, language, fraud protection) without code: Okta Telephony Inline Hook (POST /v1/verify/connectors/okta) and Auth0 custom phone provider (POST /v1/verify/connectors/auth0). Use a key with only the verify permission. These verifications have source okta / auth0; the platform checks the code itself.

Webhooks and pricing

Events to your status URL: verification.pending, verification.approved, verification.expired, verification.max_attempts, verification.canceled, verification.failed (the SMS could not be delivered to the network), signed like all webhooks. The code is never included in any response or webhook, and the SMS appears in /v1/messages with the code masked (••••••); our copy for the network is redacted as soon as the verification closes.

Pricing depends on your plan (the verification's pricing field; the Verify page in the portal shows yours):

pricingWhat you pay
per_messageThe normal price of the channel and destination (SMS, WhatsApp authentication template, voice, email) for every code sent: the first one, each resend and each fallback.
per_successOne fee per approved verification, whatever was sent. The code messages are charged as usual while the verification is open (your balance must cover a send) and credited back when it closes; a verification that is not approved costs nothing.

In both models, starting a verification that is blocked, checking codes, canceling and test numbers are free. The verification's price shows the total (with per_success: the fee once approved, otherwise 0), and each message also appears in GET /v1/messages with client_ref: "verify:<id>".

Fraud protection (SMS pumping)

Artificially inflated traffic is bots requesting codes or messages to premium or foreign numbers so that someone else earns from the traffic. These checks run on live traffic of Verify, /v1/messages and portal sends, are on by default, and every refusal is logged with what it would have cost (portal: Developers › Fraud protection).

CheckWhat happensError
Premium / high-cost rangesNumber ranges on the platform's list never receive messages.403 premium_number
Countries per API keyOptional list of countries a key may send to (Fraud protection page).403 country_not_allowed
New countriesYour usual countries (learned once from your last 90 days) and the ones you opt in are open. A country you never sent to gets a few messages a day (default 20) and you get an alert; opt it in to lift the limit.403 new_country_limit
Verify velocityCodes per number (minute, 10 min, day), IP, device (device_id), number range, destination country per hour and account; tune them per app.429 velocity_<scope>
Verify anomaliesEvery 5 minutes, per app and country: when the last hour has at least 3× the usual volume (20+ codes) and fewer than 20% are approved, the country is slowed down for an hour; if it continues, it is blocked for 24 h. Number ranges with a burst of unapproved codes are blocked too. You and the platform are alerted; one click unblocks.429 country_throttled, 403 country_blocked, 403 prefix_blocked

Send device_id (a stable fingerprint of your user's device, ≤ 64 visible characters) with POST /v1/verify to enable the per-device limit. Test keys are never limited by these checks.