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
- Create an account in the portal, top up, and request a sender ID (e.g. your brand).
- Developers → API keys → create a
test_key and alive_key. Keys are shown once. - 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"}'// Node 18+ (built-in fetch)
const res = await fetch("https://app.ogave.co/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-42-shipped",
},
body: JSON.stringify({ to: "+573001234567", from: "SHOP", text: "Your order 42 has shipped", client_ref: "order-42" }),
});
const message = await res.json();
if (!res.ok) throw new Error(`${message.error.code}: ${message.error.message} (${message.error.request_id})`);
console.log(message.id, message.status, message.price, message.currency);import os, requests
res = requests.post(
"https://app.ogave.co/v1/messages",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}", "Idempotency-Key": "order-42-shipped"},
json={"to": "+573001234567", "from": "SHOP", "text": "Your order 42 has shipped", "client_ref": "order-42"},
timeout=10,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")
print(body["id"], body["status"], body["price"], body["currency"])<?php
$ch = curl_init('https://app.ogave.co/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-42-shipped',
],
CURLOPT_POSTFIELDS => json_encode([
'to' => '+573001234567', 'from' => 'SHOP', 'text' => 'Your order 42 has shipped', 'client_ref' => 'order-42',
]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status >= 400) {
throw new RuntimeException("{$body['error']['code']}: {$body['error']['message']}");
}
echo $body['id'], ' ', $body['status'], PHP_EOL;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:
| To | Final status |
|---|---|
+15005550001 | undelivered |
+15005550002 | expired |
+15005550003 | rejected |
| any other valid number | delivered |
POST /v1/messages
| Field | Type | Description |
|---|---|---|
to * | string | E.164 (+573001234567), or a national number plus country (ISO, e.g. CO). We normalize and validate it. |
from * | string | An approved sender ID (≤ 11 chars) or a number assigned to your account. |
text * | string | Up 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_id | string | Id 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_ref | string ≤ 64 | Your reference; returned everywhere and filterable. |
scheduled_at | ISO-8601 | Send later (≤ 30 days). Earlier than now + 30 s means send now. |
validity | seconds | 60 – 259,200 (default 172,800). Expired messages are not sent and are refunded. |
callback_url | URL | Status webhooks for this message (overrides the account status URL). |
flash | boolean | Flash 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_urls | boolean | Replace 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_clicks | boolean | A 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_tracking | boolean | Default false. Lets you report a conversion for this message within 7 days: see Conversion tracking. |
category | string | transactional (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.
- Quiet hours (e.g. 21:00–08:00) are those of the recipient: the time zone comes from the number (its country, or its area code where a country has several zones; otherwise the country's main zone).
A message that would arrive in the quiet hours is accepted with
status: "scheduled",scheduled_at= end of the quiet hours anddelayed_by: "quiet_hours". Like any scheduled message it is charged when it is released and can be canceled until then. Ascheduled_atyou send is checked at that time. - Frequency cap: at most N marketing messages per number per day and/or per week (Monday–Sunday), counted in the recipient's time zone on the day the message goes out.
Over the cap the message is refused with
frequency_capped(422; in a batch only that item) and nothing is charged. Messages refused for another reason (e.g. balance) do not use up the cap. - Test keys show the same outcome (scheduled / frequency_capped) without counting towards the cap.
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.
Short links
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
| Status | Meaning |
|---|---|
scheduled | Waiting for scheduled_at (yours, or the end of the recipient's quiet hours: delayed_by). Not charged yet; can be canceled. |
queued | Accepted and charged, waiting to be submitted to the operator network. |
sent | Accepted by the operator network; waiting for the handset receipt. |
delivered | Delivered to the handset (final). |
undelivered | The operator could not deliver it; error.code has the operator error (final). |
expired | Validity ran out (final). If it expired before submission, it was refunded. |
rejected | Refused by the network at submission; refunded (final). |
failed | Could not be processed, e.g. no balance at the scheduled time (final). |
canceled | Scheduled 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.
| Field | Type | |
|---|---|---|
to | string | Required. An email address (or the phone number of a contact that has an email). |
from, from_name, reply_to | string | Optional sender identity. |
subject + html and/or text | string | Or template_id (an email template of yours) + variables for its {placeholders}. |
attachments | array | Up 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. |
tags | array | Up to 10 labels (A-Z a-z 0-9 _ . : -); filter the list with ?tag=. |
metadata | object | Up to 20 keys with text / number / boolean values (4 KB), returned on the email and its webhooks. |
category | string | transactional (default) or marketing (adds an unsubscribe link and List-Unsubscribe). |
track_opens, track_clicks | boolean | Default true. |
client_ref, callback_url | string | Your 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.
| Event | Meaning |
|---|---|
accepted | Stored and charged. |
sent | Handed to the mail provider. |
delivered | The receiving mail server accepted it. |
deferred | A temporary failure; the provider keeps trying (detail says why). |
bounced | Permanent failure: the address goes to your suppression list (the email stays charged). |
failed | Refused before sending (e.g. by the provider). |
complained | Marked as spam: the address goes to your suppression list. |
opened, clicked | Opened (tracking pixel) / a link clicked (url). Counted every time; the first 20 of each are in the timeline. |
unsubscribed | Unsubscribed (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
| HTTP | code | When |
|---|---|---|
| 422 | invalid_attachments, too_many_attachments, attachments_too_large | Attachments are not a list of {filename, content}, more than 10, or over the size limit. |
| 422 | blocked_attachment, invalid_filename, invalid_attachment_content | An executable file type, a file name with slashes, or content that is not base64. |
| 422 | invalid_tags, invalid_metadata | Tags 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));
}import hashlib, hmac, time
def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > tolerance:
return False
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
except (KeyError, ValueError):
return False<?php
function verifyWebhookSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) {
return false;
}
if (abs(time() - (int) $m[1]) > $tolerance) {
return false;
}
return hash_equals(hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret), $m[2]);
}
// $ok = verifyWebhookSignature(file_get_contents('php://input'), $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', $secret);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"
}
}
| HTTP | code | What to do |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 422 | conversion_tracking_off | The message was sent without conversion_tracking: true; send the next ones with it. |
| 409 | conversion_window_closed | Conversions can be reported within 7 days of sending. |
| 404 | no_tracked_message | No tracked or campaign message to this number in the last 7 days. |
| 415 | unsupported_media_type | Send JSON with Content-Type: application/json. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 401 | invalid_api_key | The key does not exist. |
| 401 | revoked_api_key | The key was revoked in the portal. |
| 401 | key_in_url | A key was sent in the query string. Revoke it and send keys only in the header. |
| 402 | insufficient_balance | Top up the account balance. |
| 403 | account_disabled | The account is disabled. |
| 403 | sender_not_approved | Request the sender ID in the portal and wait for approval. |
| 404 | not_found | Unknown id or endpoint (ids are scoped to the key mode). |
| 409 | not_cancelable | Only scheduled messages can be canceled. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still running; retry shortly. |
| 422 | validation_failed | A parameter is missing or invalid; see param. |
| 422 | parameter_not_allowed | A parameter that tries to pick a route (sending_server, gateway, route, server...) was sent. The platform chooses the route for every message; remove it. |
| 422 | invalid_number | The destination is not a valid phone number. |
| 422 | unsupported_country | The destination country is not enabled for the account. |
| 422 | sender_blocked | The sender (from) is blocked on this platform by the administrator (Compliance). |
| 422 | spam_content | The text contains words the platform does not allow (only when the administrator turned this check on for the API). |
| 422 | link_refused | A link in text cannot be shortened: only http/https, no local or private addresses, no other URL shorteners, not on the blocklist. |
| 422 | blocked_number | The number opted out or is blocked. |
| 422 | frequency_capped | Marketing 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. |
| 409 | idempotency_conflict | The Idempotency-Key was already used with a different body: use a new key for a new request. |
| 422 | invalid_channel | Unknown channel (in channel or in a fallback step). Use sms, whatsapp, voice or auto. |
| 422 | channel_not_available | The channel exists but is not available on the API yet. |
| 422 | no_channel_account | No active WhatsApp or voice number is connected, or account is unknown. Connect one in the portal. |
| 422 | not_sellable | The channel is not enabled for the destination country on your account. |
| 422 | opted_out | The contact opted out of this channel (e.g. replied STOP on WhatsApp), or POST /v1/contacts with a blacklisted number. |
| 422 | limit_reached | POST /v1/hooks: the account already has the maximum number of hook subscriptions. Delete unused ones. |
| 422 | unknown_template | No 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. |
| 422 | template_not_approved | The template is pending, rejected or paused (WhatsApp) or pending review, rejected or disabled (SMS); only approved templates can be sent. |
| 422 | template_required | Your 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. |
| 422 | missing_variables | template.variables has fewer values than the template needs. |
| 422 | session_closed | WhatsApp text or media outside the 24 h window. Send an approved template. |
| 422 | invalid_media | media.type must be image, document, video or audio, and media.url an https URL. |
| 422 | invalid_audio | audio_url must be an https URL. |
| 422 | unknown_flow | The ivr flow id does not exist on your account. |
| 422 | missing_content | A voice message needs tts, audio_url or ivr. |
| 422 | missing_text | The text is required (WhatsApp session message, or the channel needs text). |
| 422 | invalid_duration | fallback[].after must look like 90s, 5m or 1h, between 30 seconds and 24 hours. |
| 403 | premium_number | The number is in a premium / high-cost range. |
| 403 | new_country_limit | A new country for your account reached its daily limit: opt it in on the Fraud protection page. |
| 429 | country_throttled | Verify traffic to this country is slowed down after an anomaly; retry later. |
| 403 | country_blocked | Verify traffic to this country is blocked for this app after an anomaly (unblock in the portal). |
| 422 | no_otp_template | Verify on WhatsApp needs an approved authentication template on your WhatsApp number. |
| 429 | rate_limited | Too many requests for this key; wait Retry-After seconds. |
| 500 | internal_error | Our 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"}'
| Field | What |
|---|---|
converted_at | Optional, ISO 8601; default now. Between the sending time and now. |
value + currency | Optional: amount of the conversion and ISO 4217 currency (required with value). |
event | Optional, 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"}}
]
}'const res = await fetch("https://app.ogave.co/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-A123-shipped",
},
body: JSON.stringify({
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" } },
],
}),
});
const message = await res.json();
if (!res.ok) throw new Error(`${message.error.code}: ${message.error.message}`);
console.log(message.id, message.channel, message.status, message.whatsapp.category, message.price);import os, requests
res = requests.post(
"https://app.ogave.co/v1/messages",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}", "Idempotency-Key": "order-A123-shipped"},
json={
"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"}},
],
},
timeout=10,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["id"], body["channel"], body["status"], body["price"])<?php
$ch = curl_init('https://app.ogave.co/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-A123-shipped',
],
CURLOPT_POSTFIELDS => json_encode([
'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']],
],
]),
]);
$body = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) {
throw new RuntimeException("{$body['error']['code']}: {$body['error']['message']}");
}
echo $body['id'], ' ', $body['channel'], ' ', $body['status'], PHP_EOL;channel
| Value | Sends |
|---|---|
sms (default) | An SMS, with the fields of Send a message. |
whatsapp | A WhatsApp message from your WhatsApp number: an approved template, or session text / media. |
rcs | An RCS message from your RCS agent: text (≤ 3072) with optional suggestions, or media. See RCS, Telegram and Viber. |
telegram | A Telegram message from your bot to the chat connected to to: text (≤ 4096) or media. |
viber | A Viber message from your bot to the chat connected to to: text (≤ 7000) or media. |
voice | A phone call from your voice number: tts, audio_url or an ivr flow. |
email | An email from one of your verified domains (or the platform address): subject + html / text,
or an email_template. See Email. |
auto | We 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.
| Field | Type | Description |
|---|---|---|
template | object | name * (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. |
text | string | Session message, up to 4,096 characters. |
media | object | Session 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
| Field | Type | Description |
|---|---|---|
tts | object or string | Text 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_url | URL | An https link to a recording to play. |
ivr | string | The 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:
| Field | Type | Description |
|---|---|---|
text | string | Required unless media. RCS ≤ 3072, Telegram ≤ 4096, Viber ≤ 7000 characters. |
media | object | {"url": "https://…", "caption": "…"} — an image: RCS rich card, Telegram photo, Viber picture. https only (invalid_media). |
suggestions | array | RCS only: up to 11 suggested replies of ≤ 25 characters (invalid_suggestions). The contact's tap arrives as an inbound message. |
- RCS reaches Android phones with RCS. A phone without RCS ends
failedat once (refunded), so afallbacktosmsis sent right away instead of afterafter. A fallback step withouttextrepeats the text of the RCS message. - Telegram and Viber bots can only write to people who connected their chat. Each contact connects once: send them the bot link
(portal → Channels → Telegram / Viber → Bot link, e.g. in an SMS); opening it connects that chat to their phone number. On Telegram, people who
write to the bot first are asked to share their phone number with one tap. Sending to a number that is not connected →
not_linked(not charged). Telegram has no delivery receipts: accepted by Telegram =delivered. Viber reportsdeliveredandread. - Replies arrive in the Inbox and as
message.inboundwebhooks withchannelrcs,telegramorviber.
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"}]}'
| HTTP | Code | Meaning |
|---|---|---|
| 422 | not_linked | Telegram / Viber: this number has not connected a chat with your bot yet. Send them your bot link. |
| 422 | invalid_suggestions | RCS: more than 11 suggestions or one longer than 25 characters. |
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.
| Field | Type | Description |
|---|---|---|
to * | string | An 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. |
from | Any 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_name | string | Display name, ≤ 100 chars (default: the identity's name, else your company name). |
reply_to | Reply-To address. | |
subject | string | ≤ 255 chars. Required unless email_template is given (missing_subject). |
html | string | HTML body (≤ 512 KB). Links are rewritten to count clicks; an open pixel is added. |
text | string | Plain-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_template | string | The id of one of your email templates (Channels › Email → Templates); it gives subject, HTML,
text and category. Unknown → unknown_template. |
variables | object | Values 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. |
category | string | transactional (default) or marketing: marketing emails get an unsubscribe
link and the one-click List-Unsubscribe header. |
track_opens, track_clicks | boolean | Default 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.
| HTTP | code | When |
|---|---|---|
| 422 | suppressed | The address is on your suppression list (bounce, complaint, unsubscribe or added by you). |
| 422 | from_not_verified | from is not an address of one of your verified domains. |
| 422 | no_email | to is a phone number and no email is known for that contact. |
| 422 | invalid_email / invalid_from / invalid_reply_to | Not a valid email address. |
| 422 | missing_subject / missing_content / too_large | No subject, no body, or HTML over 512 KB. |
| 422 | no_channel_account | Email 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.
after: how long to wait for the previous attempt, as90s,5mor1h(30 seconds to 24 hours, default5m). Other formats →invalid_duration.- When
afterhas passed, the next step is sent unless the previous attempt succeeded:delivered(SMS, WhatsApp),read(WhatsApp),answeredorcompleted(voice). If a WhatsApp or voice attempt ends infailed,rejected,busy,no_answerorcanceled, the next step goes out right away (within a minute) without waiting. - Each attempt is a separate message with its own id and price, charged when it is sent. It keeps the
to,client_refandcallback_urlof the first one. An SMS step withouttextrepeats the first message's text, and withoutfromit uses your first active sender ID. - The API answers with the first message. GET
/v1/messages/{id}shows the chain infallback:previous(the attempt before this one),next(the attempt sent after it) andpending(steps not sent yet, aschannel+after_seconds).
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.
| Field | Description |
|---|---|
whatsapp | type (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. |
voice | type (tts, audio, ivr, otp), duration (seconds, once the
call ends), billable_seconds, digits (keys pressed in an IVR, comma separated). |
fallback | previous, 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:
| Status | Channel | Meaning |
|---|---|---|
delivered | Delivered to the phone. Not final on WhatsApp: it can still become read. | |
read | The contact opened it (final). Only reported when the contact has read receipts on. | |
failed | WhatsApp, voice | Not delivered, e.g. the number is not on WhatsApp or the call could not be placed (final, refunded). |
ringing | voice | The phone is ringing. |
answered | voice | The call was picked up and is in progress. |
completed | voice | The call was answered and has ended; voice.duration is set and the price is final (final). |
busy | voice | The line was busy (final, refunded). |
no_answer | voice | Nobody 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
- WhatsApp: one price per message by template category:
wa_marketing,wa_utility,wa_authentication. Session messages (text,media) inside the 24 h window use yourwa_serviceprice, which can be zero. A WhatsApp message that endsfailedorrejectedis refunded. - Voice: a price per minute (
voice_minute) billed in blocks: the first 60 seconds, then every 60 seconds started. The first block is charged when the call is placed and the rest when it ends, on the real duration (voice.billable_seconds). Calls that are not answered (busy,no_answer,failed) are refunded. - RCS, Telegram, Viber: one price per message (
rcs,telegram,viber), refunded when the message endsfailed. - Prices are per destination country and come from your plan; use quote to see them before sending. A channel that is not sold for
a country is refused with
not_sellable. Test keys are never charged.
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"}
| HTTP | code | When |
|---|---|---|
| 201 | The run: id, journey, phone, state (waiting while it runs or waits,
done, failed), step (current step id), created_at. | |
| 409 | journey_not_active | The journey is a draft or paused. Activate it in the portal. |
| 403 | live_key_required | Called with a test_ key. |
| 404 | not_found | Unknown journey id. |
| 422 | validation_failed / invalid_number | data 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
| Field | Type | Description |
|---|---|---|
party_a, party_b | string | Required. The two phone numbers (E.164), different. |
ttl | integer | Seconds until the session expires: 300 to 2592000 (30 days). Default 21600 (6 h). |
number | string | Use this proxy number (one of your active voice numbers). Default: the first free one. |
label, client_ref | string | Your 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
- GET
/v1/masking/sessions/{id}— the session with itscalls(newest first):direction(a_to_b/b_to_a),from,to,status(ringing,completed,busy,no_answer,failed,canceled,rejected),duration_seconds,billable_seconds,price. - GET
/v1/masking/sessions?status=open|closed|expired— up tolimit(100) sessions of the key's mode. - POST
/v1/masking/sessions/{id}/close— ends the session now (e.g. when the delivery is done). - POST
/v1/masking/sessions/{id}/simulate-call{"from": "+57…"}— test keys and test voice numbers only: a simulated call (the called party's last digits decide: …0000 failed, …2222 no answer, …3333 busy, otherwise 37 s).
Billing, webhooks, providers
- Each connected call is charged on its talk time at your
voice_minuteprice for the country of the called party, in the same blocks as voice calls. Calls that are not answered or rejected cost nothing. A call is only connected when your balance pays at least one block, and it is cut when the balance or the session runs out (at most 4 h). Test sessions (test key) are never charged. - When a call ends we send the webhook
masking.call.completed(or.busy,.no_answer,.failed,.canceled) to your status URL with the call fields,session_idandclient_ref. - Proxy numbers: your Twilio voice numbers (set the number's Voice webhook to the URL shown in the portal, Channels › Number masking) or test numbers.
| HTTP | Code | Meaning |
|---|---|---|
| 409 | no_proxy_available | Every proxy number you can use already has an open session with one of these numbers. Close one or add a voice number. |
| 422 | same_party | party_a and party_b are the same number. |
| 422 | invalid_ttl | ttl out of range. |
| 422 | unknown_number | number is not one of your active voice numbers with masking (Twilio or test). |
| 422 | live_number | simulate-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/hooks | event (message.delivered, message.clicked, message.inbound, contact.opted_out), target_url, optional platform (zapier | make | other) → 201 hook |
| GET | /v1/hooks | Your 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-lists | List your contact lists · create one (name) |
| POST | /v1/contacts | Add 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"}'const api = (path, body) => fetch(`https://app.ogave.co/v1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => r.json());
// 1) send the code (store verification.id in the user's session)
const verification = await api("/verify", { to: "+573001234567", locale: "es", ip: req.ip });
// 2) check what the user typed
const check = await api(`/verify/${verification.id}/check`, { code: userInput });
if (check.result === "approved") { /* log the user in */ }
else if (check.result === "invalid") { /* show "wrong code", check.attempts_remaining left */ }
else { /* expired or max_attempts: offer to start again */ }import os, requests
API = "https://app.ogave.co/v1"
H = {"Authorization": f"Bearer {os.environ['API_KEY']}"}
v = requests.post(f"{API}/verify", headers=H, json={"to": "+573001234567", "locale": "es", "ip": client_ip}).json()
# ... later, with the code the user typed:
check = requests.post(f"{API}/verify/{v['id']}/check", headers=H, json={"code": user_code}).json()
approved = check.get("result") == "approved"<?php
function api(string $path, array $body): array {
$ch = curl_init('https://app.ogave.co/v1' . $path);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($body)]);
return json_decode(curl_exec($ch), true);
}
$v = api('/verify', ['to' => '+573001234567', 'locale' => 'es', 'ip' => $_SERVER['REMOTE_ADDR']]);
$_SESSION['verification_id'] = $v['id'];
// ... later:
$check = api('/verify/' . $_SESSION['verification_id'] . '/check', ['code' => $_POST['code']]);
$approved = ($check['result'] ?? null) === 'approved';POST /v1/verify
| Field | Description |
|---|---|
to * | Phone number (E.164, or national + country); with channel: email, the email address. |
app_id | Verify app to use (settings below). Default: your first app; a "Default" app is created if you have none. |
channel | sms (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_order | 1–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. |
email | Your end user's email address, for the email fallback. Optional. |
fallback_after | Seconds to wait before the next channel of channel_order: 30–600 (default 60). |
code_length | 4–10 (default from the app, 6). |
code_type | numeric or alphanumeric (no look-alike characters 0/O/1/I/L). |
expiry_seconds | 60–3,600 (default 600). |
template | Your own text with {code} (required), {app_name}, {minutes}. Otherwise the built-in text for locale. |
locale | en, es, pt, fr, de, it (default from the app). See GET /v1/verify/templates. |
sender_id | An approved sender ID (default from the app). |
ip | Your end user's IP address. Optional but recommended: enables per-IP fraud limits. |
device_id | A 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:
| result | Meaning |
|---|---|
approved | Correct code. The verification is closed. |
invalid | Wrong code; attempts_remaining tells how many tries are left. |
expired | The code expired. Start a new verification. |
max_attempts | Too 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):
| Scope | Default limit | Error |
|---|---|---|
| number | 3 per 1 min, 5 per 10 min, 10 per 24 h | velocity_number (429) |
| device | 5 per 10 min, 20 per 24 h | velocity_device (429) |
| ip | 20 per 10 min | velocity_ip (429) |
| prefix | 30 per 10 min | velocity_prefix (429) |
| country | 1000 per 1 h | velocity_country (429) |
| account | 600 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):
pricing | What you pay |
|---|---|
per_message | The 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_success | One 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).
| Check | What happens | Error |
|---|---|---|
| Premium / high-cost ranges | Number ranges on the platform's list never receive messages. | 403 premium_number |
| Countries per API key | Optional list of countries a key may send to (Fraud protection page). | 403 country_not_allowed |
| New countries | Your 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 velocity | Codes 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 anomalies | Every 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.