# 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](https://app.ogave.co/docs/api/openapi.json) ([YAML](https://app.ogave.co/openapi.yaml)) · [Postman collection](https://app.ogave.co/docs/api/postman_collection.json) + [environment](https://app.ogave.co/docs/api/postman_environment.json) · [SDKs & tools](https://app.ogave.co/docs/sdks).

## Quick start {#quickstart}

1. Create an account in the [portal](https://app.ogave.co/login), 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.

```bash
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"}'
```

```javascript
// 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);
```

```python
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
<?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](#message-object): `status` is `queued`, and you get `encoding`, `segments` and `price` right away.

## Authentication {#auth}

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`](#errors-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-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 {#send}

| 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](#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](#conversions). |
| `category` | string | `transactional` (default) or `marketing`. Marketing messages follow your [sending rules](#sending-rules) (frequency cap, quiet hours at the recipient). Verify / OTP messages are always transactional. |

### Sending rules: frequency cap and quiet hours {#sending-rules}

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 and `delayed_by: "quiet_hours"`. Like any scheduled message it is charged when it is released and can be canceled until then. A `scheduled_at` you 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`](#errors-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 {#sms-templates}

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`](#errors-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 {#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`](#errors-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 {#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 {#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 {#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](#channel-statuses).

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

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} {#schedule}

Cancels a `scheduled` message. Other statuses return [`not_cancelable`](#errors-not_cancelable).

## POST /v1/emails {#email}

Send one email from your app and follow it through delivery. Use an address of one of your **verified domains** ([GET /v1/email-identities](#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](#idempotency) 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 {#email-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 {#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](#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 {#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 {#email-providers}

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 {#balance}

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 {#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](#verify) events. WhatsApp and voice add more [status events](#channel-webhooks) 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 {#signature}

`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).

```javascript
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));
}
```

```python
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
<?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 {#retries}

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 {#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 {#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 {#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 {#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 {#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 {#conversions}

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) {#conversions-by-number}

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 {#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 {#channels}

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](#message-object) (`msg_…`) with its own status, price, webhooks and report row, so `GET /v1/messages`, `client_ref`, `callback_url` and [idempotency](#idempotency) work the same on every channel.

```bash
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"}}
    ]
  }'
```

```javascript
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);
```

```python
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
<?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` {#channel-field}

| Value | Sends |
| --- | --- |
| `sms` (default) | An SMS, with the fields of [Send a message](#send). |
| `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](#messaging-apps). |
| `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](#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.

### WhatsApp {#whatsapp}

| 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`](#errors-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`](#errors-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`](#errors-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 {#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`](#errors-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 {#messaging-apps}

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`](#errors-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 `failed` at once (refunded), so a `fallback` to `sms` is sent right away instead of after `after`. A fallback step without `text` repeats 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`](#errors-not_linked) (not charged). Telegram has no delivery receipts: accepted by Telegram = `delivered`. Viber reports `delivered` and `read`.
- Replies arrive in the Inbox and as `message.inbound` webhooks with `channel` `rcs`, `telegram` or `viber`.

```
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. |

### Email {#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.

| 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`](#errors-no_email). |
| `from` | email | Any address of one of your **verified** domains, e.g. `news@mail.yourshop.com`. Not verified → [`from_not_verified`](#errors-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` | email | 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`](#errors-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}

`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, as `90s`, `5m` or `1h` (30 seconds to 24 hours, default `5m`). Other formats → [`invalid_duration`](#errors-invalid_duration).
- When `after` has passed, the next step is sent unless the previous attempt succeeded: `delivered` (SMS, WhatsApp), `read` (WhatsApp), `answered` or `completed` (voice). If a WhatsApp or voice attempt ends in `failed`, `rejected`, `busy`, `no_answer` or `canceled`, 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_ref` and `callback_url` of the first one. An SMS step without `text` repeats the first message's text, and without `from` it uses your first active sender ID.
- The API answers with the first message. GET `/v1/messages/{id}` shows the chain in `fallback`: `previous` (the attempt before this one), `next` (the attempt sent after it) and `pending` (steps not sent yet, as `channel` + `after_seconds`).

### POST /v1/messages/quote {#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 {#channel-object}

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](#fallback). |

```
{
  "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 {#channel-statuses}

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

| Status | Channel | Meaning |
| --- | --- | --- |
| `delivered` | WhatsApp | Delivered to the phone. Not final on WhatsApp: it can still become `read`. |
| `read` | WhatsApp | 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 {#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](#signature).  
 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](#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 {#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 your `wa_service` price, which can be zero. A WhatsApp message that ends `failed` or `rejected` is 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 ends `failed`.
- Prices are per destination country and come from your plan; use [quote](#quote) to see them before sending. A channel that is not sold for a country is refused with [`not_sellable`](#errors-not_sellable). Test keys are never charged.

## POST /v1/journeys/{id}/runs {#journeys}

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 {#masking}

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 {#masking-create}

| 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 {#masking-other}

- GET `/v1/masking/sessions/{id}` — the session with its `calls` (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 to `limit` (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 {#masking-billing}

- Each connected call is charged on its talk time at your `voice_minute` price for the country of the called party, in the same blocks as [voice calls](#channel-pricing). 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_id` and `client_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 {#integrations}

For Zapier, Make and your own automations. Full guide, event payloads and the Shopify, WooCommerce, HubSpot and Google Sheets connections: [Integrations docs](https://app.ogave.co/docs/integrations).

### REST hooks {#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](#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 {#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 {#verify}

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.

```bash
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"}'
```

```javascript
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 */ }
```

```python
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
<?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`](#errors-no_otp_template)), `voice` (a call that reads the code aloud), `email` (the code by email to `to`: [Verify by email](#verify-email-channel)) 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](#verify-email) for this request even when the app has it switched off. |
| `email` | Your end user's email address, for the [email fallback](#verify-email). 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](#fraud). |

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 {#verify-email-channel}

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`](#errors-suppressed), nothing charged.

### Email fallback {#verify-email}

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 {#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) {#verify-fraud}

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 {#verify-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 {#verify-okta-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](https://app.ogave.co/docs/integrations/okta) (POST `/v1/verify/connectors/okta`) and [Auth0 custom phone provider](https://app.ogave.co/docs/integrations/auth0) (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 {#verify-webhooks}

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](#signature). 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) {#fraud}

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.
