<!-- source: https://app.ogave.co/docs/api -->
# 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.


---

<!-- source: https://app.ogave.co/docs/smpp -->
# SMPP

Connect your own system to Ogave SMS by **SMPP 3.4**. Messages are billed exactly like the portal and the HTTP API: your plan prices (including your provider's prices if you buy through a reseller), from your platform balance. Each message is checked and charged **before** it is accepted.

## Connection {#connect}

| Host | `smpp.app.ogave.co` |
| --- | --- |
| Port | `2776` (plain) · `3550` (TLS 1.2+) |
| Bind | transceiver (recommended), transmitter, receiver |
| Credentials | Portal › Developers › SMPP: system_id and password (8 characters, shown once). **Test** accounts are accepted but simulated: never sent, never charged. |
| Limits | Per account: binds, messages per second, window (shown in the portal). |
| Allowed IPs | **Required.** Connections are accepted only from the IPs on the account's whitelist (see below). |
| Keep-alive | Send `enquire_link` every 30–60 s; idle binds are closed after 120 s without traffic. |
| Failed bind | Answered after 1 s, then the connection is closed. |

## IP whitelist (required) {#whitelist}

Like a carrier SMPP gateway, the platform accepts connections only from IP addresses you allow. Each SMPP account needs at least one entry in **Developers › SMPP › Allowed IPs**; an account without entries cannot bind. Connections from other IPs are dropped by the firewall, or refused with `ESME_RBINDFAIL` (0x0D) before the password is checked.

- **Single addresses or ranges** (CIDR): `203.0.113.10`, `198.51.100.0/24`. The widest range allowed is **/16** for IPv4.
- **IPv6** is supported: `2001:db8::10`, `2001:db8:1200::/48`; the widest range is **/32**. Connect to the IPv6 address of the host to use it.
- Use the **public** IP your system connects from (the portal shows your current IP with a one-click add). Private, loopback, link-local, multicast and reserved addresses (10.x, 192.168.x, 127.x, fe80::, …) and `0.0.0.0/0` are refused.
- Changes apply **within 1 minute**. When you remove an IP, open binds from it are closed.
- Refused attempts on your accounts (IP, time, reason) are listed on the same page, kept 30 days.

## submit_sm {#submit}

- **source_addr**: an approved sender ID (alphanumeric TON 5) or your number (TON 1). **destination_addr**: international format (TON 1, NPI 1), with or without +.
- **data_coding**: 0 (GSM 7-bit default alphabet), 3 (Latin-1), 8 (UCS-2). Flash: 0x10 / 0x18 (class 0).
- **Long messages**: send the text in one `message_payload` TLV, or as concatenated parts (UDH 8-bit or 16-bit reference, or `sar_*` TLVs). Parts are joined and charged once as one message; every part gets the same `message_id`. If a part is missing after 30 s all parts get 0x45.
- **registered_delivery**: 1 (or any final-receipt value) to get a delivery receipt on your bind.
- **validity_period**: absolute or relative; default 48 h, at most 7 days.
- **schedule_delivery_time** is ignored: messages are sent at once (schedule from the portal or the API instead).
- Also accepted: data_coding 1 (ASCII) and 0xF0 (flash). Not supported: data_sm, query_sm, submit_multi, replace_sm, cancel_sm (answered with generic_nack 0x03).

**Limits count PDUs.** Messages per second count each submit_sm, so every part of a long message counts. The parts of a long message are answered together when its last part arrives: a long message can have at most as many parts as your window, and your client's own window must be larger than its number of parts.

## Delivery receipts {#dlr}

deliver_sm with `esm_class` 0x04 and the standard text, with **our** message id (the one we returned in submit_sm_resp):

```
id:msg_01h... sub:001 dlvrd:001 submit date:2611011030 done date:2611011031 stat:DELIVRD err:000 text:Your code is 1234
```

Receipts are sent with data_coding 0 and plain ASCII text. stat is one of DELIVRD, UNDELIV, EXPIRED, REJECTD, DELETED. Also as TLVs: `receipted_message_id`, `message_state`. If you are not bound, receipts wait (72 h by default) and are sent when you bind with a receiver or transceiver.

| stat | Meaning | Final |
| --- | --- | --- |
| `ENROUTE` | The message is on its way (intermediate) | no |
| `DELIVRD` | Delivered to the handset | yes |
| `EXPIRED` | Not delivered before its validity ran out (phone off or out of coverage) | yes |
| `DELETED` | Deleted by the network before delivery | yes |
| `UNDELIV` | Undeliverable (invalid, barred or unreachable number) | yes |
| `ACCEPTD` | Accepted by the network (intermediate, or read on behalf of the subscriber) | no |
| `UNKNOWN` | The network reported no final state | yes |
| `REJECTD` | Rejected by the network (route, content or sender) | yes |

Every step of a message — when it was received, the checks, the charge, the answer of the network and each receipt, decoded — is in the portal: Reports › All messages › the route icon of the message (**message trace**).

## Replies (MO) {#mo}

Replies to your numbers arrive as deliver_sm (`esm_class` 0, data_coding 3 Latin-1 or 8 UCS-2) on your receiver/transceiver bind, if "Send replies (MO)" is on for the account. They are also in the portal inbox and your inbound webhook.

## submit_sm_resp status codes {#errors}

| Status | Name | Meaning |
| --- | --- | --- |
| `0x00000000` | ESME_ROK | Accepted: the message is charged and queued. message_id = our message id (msg_…), the same as in the portal and the API. |
| `0x0000000E / 0x0000000F` | ESME_RINVPASWD / ESME_RINVSYSID | bind: wrong password / unknown system_id. |
| `0x0000000D` | ESME_RBINDFAIL | bind: IP not in the account whitelist (or the whitelist is empty), account disabled, or too many binds open. |
| `0x00000058` | ESME_RTHROTTLED | More messages per second than your limit. Slow down and retry: nothing was accepted. |
| `0x00000014` | ESME_RMSGQFUL | Your window is full (too many submit_sm without response). Wait for responses. |
| `0x0000000A` | ESME_RINVSRCADR | Sender ID not approved for your account. |
| `0x0000000B` | ESME_RINVDSTADR | Not a valid destination number. |
| `0x00000045` | ESME_RSUBMITFAIL | Any other refusal (message text in our logs). |
| `0x00000401` | vendor: insufficient balance | Your balance does not cover the message. Top up in the portal. Nothing was sent or charged. |
| `0x00000403` | vendor: country not enabled | The destination country is not enabled on your plan. |
| `0x00000405` | vendor: blocked number | The number opted out or is on your blacklist. |
| `0x00000406` | vendor: template required | Your plan only allows approved templates; the text does not match one. |
| `0x00000407` | vendor: account disabled | The account was disabled. |
| `0x00000408` | vendor: provider balance | Sending is paused by your provider (reseller). Contact them. |
| `0x00000409` | vendor: link refused | A link in the text cannot be used (private address, another shortener, blocklisted). |

## SMPP 3.4 command_status reference {#smpp-codes}

The standard codes (SMPP 3.4 §5.1.3) that SMPP servers return, for reference when you also connect to other gateways. Codes from 0x00000400 are vendor-specific: ours are in the table above.

| Hex | Dec | Name | Meaning | What to do |
| --- | --- | --- | --- | --- |
| `0x00000000` | 0 | ESME_ROK | No error | Nothing: the message was accepted. |
| `0x00000001` | 1 | ESME_RINVMSGLEN | Message length is invalid | Check the encoding and the length of short_message (max 254 octets; longer texts go in message_payload or as concatenated parts). |
| `0x00000002` | 2 | ESME_RINVCMDLEN | Command length is invalid | The PDU is malformed: check the client library or the PDU encoder. |
| `0x00000003` | 3 | ESME_RINVCMDID | Invalid command ID | The server does not support this command (e.g. submit_multi or query_sm): use submit_sm. |
| `0x00000004` | 4 | ESME_RINVBNDSTS | Incorrect bind status for this command | Bind as transmitter or transceiver to submit, as receiver or transceiver to get receipts. |
| `0x00000005` | 5 | ESME_RALYBND | Already bound | The session is already bound: do not send a second bind on the same connection. |
| `0x00000006` | 6 | ESME_RINVPRTFLG | Invalid priority flag | Use priority_flag 0 to 3 (most gateways ignore it: send 0). |
| `0x00000007` | 7 | ESME_RINVREGDLVFLG | Invalid registered delivery flag | Use registered_delivery 0 (no receipt) or 1 (receipt on success and failure). |
| `0x00000008` | 8 | ESME_RSYSERR | System error | A temporary problem on the server side: retry later with back-off; contact support if it persists. |
| `0x0000000A` | 10 | ESME_RINVSRCADR | Invalid source address | Check the sender: alphanumeric max 11 characters (TON 5, NPI 0), or a number in international format (TON 1, NPI 1). |
| `0x0000000B` | 11 | ESME_RINVDSTADR | Invalid destination address | Send the number in international format without "+" or leading zeros (TON 1, NPI 1). |
| `0x0000000C` | 12 | ESME_RINVMSGID | Message ID is invalid | The message_id does not exist or is not yours (query_sm / cancel_sm). |
| `0x0000000D` | 13 | ESME_RBINDFAIL | Bind failed | Check system_id, password, the allowed IP addresses and how many binds the account may open. |
| `0x0000000E` | 14 | ESME_RINVPASWD | Invalid password | Check the password of the SMPP account (case-sensitive, max 8 characters on many servers). |
| `0x0000000F` | 15 | ESME_RINVSYSID | Invalid system ID | Check the system_id of the SMPP account. |
| `0x00000011` | 17 | ESME_RCANCELFAIL | Cancel SM failed | The message could not be cancelled (already sent or not found). |
| `0x00000013` | 19 | ESME_RREPLACEFAIL | Replace SM failed | The message could not be replaced (already sent or not found). |
| `0x00000014` | 20 | ESME_RMSGQFUL | Message queue full | Slow down and retry: the server queue is full. |
| `0x00000015` | 21 | ESME_RINVSERTYP | Invalid service type | Leave service_type empty unless your provider gave you a value. |
| `0x00000033` | 51 | ESME_RINVNUMDESTS | Invalid number of destinations | submit_multi: check number_of_dests. |
| `0x00000034` | 52 | ESME_RINVDLNAME | Invalid distribution list name | submit_multi: check the distribution list name. |
| `0x00000040` | 64 | ESME_RINVDESTFLAG | Invalid destination flag | submit_multi: dest_flag must be 1 (address) or 2 (list). |
| `0x00000042` | 66 | ESME_RINVSUBREP | Invalid submit with replace | Set replace_if_present_flag to 0. |
| `0x00000043` | 67 | ESME_RINVESMCLASS | Invalid esm_class field | Use esm_class 0, or 0x40 when the text carries a UDH (concatenated parts). |
| `0x00000044` | 68 | ESME_RCNTSUBDL | Cannot submit to distribution list | Submit to single numbers. |
| `0x00000045` | 69 | ESME_RSUBMITFAIL | submit_sm or submit_multi failed | The server refused the message (route, balance or content): check the account and retry later. |
| `0x00000048` | 72 | ESME_RINVSRCTON | Invalid source address TON | Alphanumeric sender: TON 5; international number: TON 1. |
| `0x00000049` | 73 | ESME_RINVSRCNPI | Invalid source address NPI | Alphanumeric sender: NPI 0; international number: NPI 1. |
| `0x00000050` | 80 | ESME_RINVDSTTON | Invalid destination address TON | Use TON 1 (international). |
| `0x00000051` | 81 | ESME_RINVDSTNPI | Invalid destination address NPI | Use NPI 1 (ISDN / E.164). |
| `0x00000053` | 83 | ESME_RINVSYSTYP | Invalid system_type field | Leave system_type empty unless your provider gave you a value. |
| `0x00000054` | 84 | ESME_RINVREPFLAG | Invalid replace_if_present flag | Set replace_if_present_flag to 0. |
| `0x00000055` | 85 | ESME_RINVNUMMSGS | Invalid number of messages | Check the request parameters. |
| `0x00000058` | 88 | ESME_RTHROTTLED | Throttling error (too many messages per second) | Send slower than the speed of your account, or spread the traffic over more binds if allowed; retry the refused messages. |
| `0x00000061` | 97 | ESME_RINVSCHED | Invalid scheduled delivery time | Use the SMPP absolute (YYMMDDhhmmsstnnp) or relative (…R) time format, or leave it empty. |
| `0x00000062` | 98 | ESME_RINVEXPIRY | Invalid validity period | Use the SMPP time format, or leave it empty for the default validity. |
| `0x00000063` | 99 | ESME_RINVDFTMSGID | Predefined message invalid or not found | Set sm_default_msg_id to 0. |
| `0x00000064` | 100 | ESME_RX_T_APPN | Receiver temporary application error | Temporary: retry later. |
| `0x00000065` | 101 | ESME_RX_P_APPN | Receiver permanent application error | Permanent: do not retry this message as it is. |
| `0x00000066` | 102 | ESME_RX_R_APPN | Receiver reject message error | The message was refused: do not retry it as it is. |
| `0x00000067` | 103 | ESME_RQUERYFAIL | query_sm request failed | The message could not be queried (unknown or too old). |
| `0x000000C0` | 192 | ESME_RINVOPTPARSTREAM | Error in the optional part of the PDU body | A TLV is malformed: check the client library. |
| `0x000000C1` | 193 | ESME_ROPTPARNOTALLWD | Optional parameter not allowed | Remove the TLV the server does not accept. |
| `0x000000C2` | 194 | ESME_RINVPARLEN | Invalid parameter length | A TLV has a wrong length: check the client library. |
| `0x000000C3` | 195 | ESME_RMISSINGOPTPARAM | Expected optional parameter missing | Add the TLV the server requires (see the provider documentation). |
| `0x000000C4` | 196 | ESME_RINVOPTPARAMVAL | Invalid optional parameter value | Check the value of the TLVs you send. |
| `0x000000FE` | 254 | ESME_RDELIVERYFAILURE | Delivery failure (data_sm_resp) | The message could not be delivered: see the receipt or retry later. |
| `0x000000FF` | 255 | ESME_RUNKNOWNERR | Unknown error | An unspecified error on the server: contact support with the message ID and the time. |


---

<!-- source: https://app.ogave.co/docs/sdks -->
# SDKs & tools

Official v1 API clients in four languages, the OpenAPI spec and a Postman collection. Use a test_ key to try everything: nothing is sent or charged. Base URL: `https://app.ogave.co`

## SDKs {#sdks}

### PHP 8.1+

[Download messaging-api-php-1.1.0.tar.gz](https://app.ogave.co/sdks/messaging-api-php-1.1.0.tar.gz) sha256 929fa5c78987069f1f392eac8862468b72b683ebfd6bc73f8bb160f064363369

```
composer config repositories.msg artifact ./dist
composer require messaging-api/sdk:1.1.0
```

```
$client = new MessagingApi\Client(getenv('API_KEY'), 'https://app.ogave.co');
$m = $client->send(['to' => '+573001234567', 'from' => 'SHOP', 'text' => 'Hello']);
```

### Node.js 18+ / TypeScript

[Download messaging-api-1.1.0.tgz](https://app.ogave.co/sdks/messaging-api-1.1.0.tgz) sha256 5da13ec310996529fff216292219351f034a62ed2cbc4cda00dc9dbe4c115976

```
npm install ./messaging-api-1.1.0.tgz
```

```
const { Client } = require('messaging-api');
const client = new Client(process.env.API_KEY, 'https://app.ogave.co');
const m = await client.send({ to: '+573001234567', from: 'SHOP', text: 'Hello' });
```

### Python 3.9+

[Download messaging_api-1.1.0.tar.gz](https://app.ogave.co/sdks/messaging_api-1.1.0.tar.gz) sha256 95925e9053605f09cb7d74c00e845afe7c1d928f5f15794f4f481123842b803f

```
pip install messaging_api-1.1.0.tar.gz
```

```
from messaging_api import Client
client = Client(os.environ['API_KEY'], 'https://app.ogave.co')
m = client.send({'to': '+573001234567', 'from': 'SHOP', 'text': 'Hello'})
```

### Java 11+

[Download messaging-api-1.1.0.jar](https://app.ogave.co/sdks/messaging-api-1.1.0.jar) sha256 75de751503b4c0bf94aace101e12760ad7366c590630806b3af2154de99d3791

```
mvn install:install-file -Dfile=messaging-api-1.1.0.jar -DpomFile=messaging-api-1.1.0.pom
```

```
Client client = new Client(System.getenv("API_KEY"), "https://app.ogave.co");
Map<String, Object> m = client.send(Map.of("to", "+573001234567", "from", "SHOP", "text", "Hello"));
```

Java sources (with pom.xml): [messaging-api-java-1.1.0.tar.gz](https://app.ogave.co/sdks/messaging-api-java-1.1.0.tar.gz)

## Command line (msg) {#cli}

Node 18+, no dependencies: SMS, email, Verify and following delivery live from a terminal or a script. [Download messaging-cli-1.1.0.tgz](https://app.ogave.co/sdks/messaging-cli-1.1.0.tgz) sha256 e6c43c9209954d790dbbead1e2b102ab4c4dbe085bb43e1bea366cab12916d08

```
npm install -g ./messaging-cli-1.1.0.tgz
msg config set host https://app.ogave.co
msg config set key live_…
msg sms send --to +573001234567 --from SHOP --text "Hello"
msg email send --to ana@example.com --subject "Receipt" --text "Thanks" --attach receipt.pdf
msg verify start --to ana@example.com --channel email
msg messages tail --since 10m
```

## What they do {#features}

- Messages (send, batch, quote, get, list, cancel), email (send, get, list, identities — 1.1.0), Verify, contacts and lists, hooks, conversions, balance and pricing; any other endpoint with request().
- Retries with exponential backoff and Retry-After on 429, 5xx and network errors; POSTs carry an automatic Idempotency-Key, so a retry never sends twice.
- Typed errors: AuthenticationError, PermissionError, NotFoundError, ValidationError, RateLimitError (status, code, param, request id).
- Webhook signature check (Webhook-Signature header, 5-minute tolerance).

## OpenAPI 3.1 {#openapi}

[openapi.json](https://app.ogave.co/docs/api/openapi.json) · [openapi.yaml](https://app.ogave.co/openapi.yaml) — checked against the real API routes; use it to generate clients in other languages.

## Postman {#postman}

[Collection](https://app.ogave.co/docs/api/postman_collection.json) + [environment](https://app.ogave.co/docs/api/postman_environment.json). Import both and put your test_ key in apiKey.


---

<!-- source: https://app.ogave.co/docs/mcp -->
# AI agents (MCP)

An MCP (Model Context Protocol) server so your AI assistant can send SMS, check deliveries, manage contacts and Verify codes in your account. Server URL:

```
https://app.ogave.co/mcp
```

## Connect {#connect}

Recommended: add the URL in your AI app and sign in when it asks (OAuth). A screen lets you choose test or live mode and the permissions. Disconnect it any time in Developers › API keys › Connected AI apps. Team members can only give the permissions of their role.

### Claude {#claude}

Claude (web and desktop): Settings › Connectors › Add custom connector › the URL above. Claude Code:

```
claude mcp add --transport http ogave-sms https://app.ogave.co/mcp
```

### ChatGPT {#chatgpt}

Settings › Connectors (developer mode) › Create › MCP server URL = the one above, authentication OAuth.

### Cursor {#cursor}

```
// ~/.cursor/mcp.json
{ "mcpServers": { "ogave-sms": { "url": "https://app.ogave.co/mcp" } } }
```

### VS Code {#vscode}

```
// .vscode/mcp.json
{ "servers": { "ogave-sms": { "type": "http", "url": "https://app.ogave.co/mcp" } } }
```

### With an API key (scripts, servers) {#api-key}

Create a key in Developers › API keys with the mcp permission (plus the ones the tools need) and send it as a header:

```
Authorization: Bearer test_…
// Cursor / VS Code: "headers": { "Authorization": "Bearer ${env:API_KEY}" }
```

With a test_ key everything is simulated: nothing is sent or charged.

## Tools {#tools}

| Tool | What it does | Permission |
| --- | --- | --- |
| `quote_price` | Price, segments and encoding of one SMS before sending it. Nothing is sent. | `balance:read` |
| `send_sms` | Send one SMS. Without confirm: true it only returns the quote; show it to the user and ask before confirming. | `messages:send` |
| `send_batch` | Send one text to a contact list or to up to 10,000 numbers per call. dry_run (default true) returns recipients, segments and total cost; send with dry_run: false after the user agrees. | `messages:send` |
| `get_message` | Status, receipt, price and error of one message. | `messages:read` |
| `list_messages` | Newest first, with filters. | `messages:read` |
| `get_balance` | Balance and currency of the account. | `balance:read` |
| `list_contact_lists` | Contact lists with their ids and sizes. | `contacts:read` |
| `add_contacts` | Add or update up to 100 contacts in a list. Without confirm: true it only validates. | `contacts:write` |
| `verify_start` | Send a Verify one-time code at once (no confirmation needed): by SMS to a phone number, by email to an address (channel email), or SMS first and the code by email if it is not verified in time (channel auto, give email). | `verify` |
| `verify_check` | Check the code the person received. | `verify` |
| `get_report` | Messages, delivery rate and cost for a period, grouped. | `reports:read` |
| `send_email` | Send a transactional email to one address or up to 10,000 (one email each) from a sending identity of the account (see list_email_identities). Assistant mode: without confirm: true it only returns the price; show it to the user and ask before confirming. | `email:send` |
| `get_email` | One email with its delivery timeline (accepted, sent, delivered, bounced, opened, clicked…). | `email:read` |
| `list_emails` | Newest first, each with its timeline. Filters: status, to, tag. | `email:read` |
| `list_email_identities` | The sending domains and addresses the account may send email from. | `email:read` |
| `list_sender_ids` | Sender IDs approved on the account. | `messages:read` |
| `list_templates` | Approved SMS templates (id, name, text with {placeholders}). | `messages:read` |

Resources: the API documentation (docs://api), this guide (docs://mcp) and prices per country (pricing://countries). Prompts: "write and send a campaign", "check delivery of the last campaign".

## Modes: assistant or automatic {#modes}

Every API key or connected app has a mode (Developers › API keys › AI agent):

- **Assistant** (default): for people using an AI app by hand. send_sms, send_email and add_contacts only act with confirm: true; without it they return the price or what they would do. send_batch is a dry run unless dry_run: false. The server instructions tell the agent to ask first.
- **Automatic**: for automating tasks. send_sms, send_batch, send_email and add_contacts act on the first call, within the key's limits: max spend per day (required), recipients per call, and optionally the allowed sender IDs, email senders, templates and hours. A send over a limit returns a clear error and nothing is sent (never a partial send). Revoking the key stops it at once.
- verify_start (by SMS, email or auto) never needs a confirmation: one recipient, asked by the app, protected by Verify's fraud limits.

## Limits {#safety}

- At most 10,000 recipients per call (safety limit, send_email too); for more, use portal campaigns.
- The account may raise it up to 100,000 in Developers › API keys › AI agents, by explicitly accepting that an AI agent may make mistakes (wrong list, wrong text) and that sends above 10,000 recipients are its own responsibility; a send_batch result above 10,000 carries a warning. Lowering it needs nothing.
- Email: send_email sends one email per address from a sending identity of the account (list_email_identities); get_email and list_emails return the timeline (delivered, bounced, opened…). Scopes email:send / email:read.
- Same rate limits as your API key; every send (and every send refused in automatic mode) is in the activity log (Account › Team › Activity).

## Docs for AI tools {#llms}

To let an assistant read these docs without the HTML: [`https://app.ogave.co/llms.txt`](https://app.ogave.co/llms.txt) (index), [`/llms-full.txt`](https://app.ogave.co/llms-full.txt) (every page in one file) and each page as Markdown by adding `.md` to its address, e.g. [`/docs/api.md`](https://app.ogave.co/docs/api.md).


---

<!-- source: https://app.ogave.co/docs/integrations -->
# Integrations

Connect Ogave SMS to the tools you already use. Everything goes through the same engine as the API: your plan prices, your balance, your sender IDs, blacklist and opt-outs. Manage connections in the portal under **Integrations**.

| Platform | What it does | How it connects |
| --- | --- | --- |
| [Zapier](https://app.ogave.co/docs/integrations/zapier) · [Make](https://app.ogave.co/docs/integrations/make) | Instant triggers: message delivered, link clicked, inbound message, contact opted out. Actions: send SMS, add / update contact. | Your API key (REST hooks) |
| [Shopify](https://app.ogave.co/docs/integrations/shopify) | Abandoned cart, order created, order shipped (tracking), OTP codes | OAuth app install |
| [WooCommerce](https://app.ogave.co/docs/integrations/woocommerce) | Abandoned checkout, order created, order completed (tracking), OTP codes | REST API keys or a webhook secret |
| [Tiendanube / Nuvemshop](https://app.ogave.co/docs/integrations/tiendanube) | Abandoned cart, order created / paid / shipped | Partner app install (OAuth) |
| [VTEX](https://app.ogave.co/docs/integrations/vtex) | Order created / paid / invoiced, abandoned carts from a trigger | App key + app token |
| [PrestaShop](https://app.ogave.co/docs/integrations/prestashop) | Abandoned cart, order created / paid / shipped | Webservice key (read every few minutes) |
| [Magento 2 / Adobe Commerce](https://app.ogave.co/docs/integrations/magento) | Abandoned cart, order created / paid / shipped | Integration access token (read every few minutes) |
| [Jumpseller](https://app.ogave.co/docs/integrations/jumpseller) | Abandoned checkout, order created / paid / shipped | API login + token, hooks token |
| [HubSpot](https://app.ogave.co/docs/integrations/hubspot) | Two-way contact sync (on demand + scheduled), SMS logged on the contact timeline | OAuth |
| [Zoho CRM](https://app.ogave.co/docs/integrations/zoho) | Contacts or leads both ways, opt-outs, SMS and replies as notes, send SMS from workflow rules | OAuth |
| [Google Sheets](https://app.ogave.co/docs/integrations/google-sheets) | Keep a contact list in sync with a sheet | Public link, or OAuth for private sheets |
| [Okta](https://app.ogave.co/docs/integrations/okta) | Okta's SMS codes (MFA, phone enrollment) delivered with your Verify app | Telephony inline hook + API key (verify) |
| [Auth0](https://app.ogave.co/docs/integrations/auth0) | Auth0's SMS codes (MFA, enrollment) delivered with your Verify app | Custom phone provider Action + API key (verify) |

## REST hooks {#hooks}

Subscribe a URL to one event. Events are delivered by our webhook system: signed (`Webhook-Signature`, same scheme as [API webhooks](https://app.ogave.co/docs/api#webhooks)), retried with backoff, listed in `GET /v1/webhooks/deliveries`. A hook belongs to the mode of the key that created it: test keys get test events only. Answer `410 Gone` to delete the subscription.

| POST | `/v1/hooks` | `{"event": "message.delivered", "target_url": "https://…", "platform": "zapier"}` → `201` `{"id": "hook_…", …}` |
| --- | --- | --- |
| GET | `/v1/hooks` | Your subscriptions (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 (for field mapping) |

```
curl -X POST https://app.ogave.co/v1/hooks -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"event":"message.inbound","target_url":"https://hooks.example.com/sms"}'
```

## Events {#events}

Every delivery has the same envelope: `{"id": "evt_…", "type": "<event>", "mode": "live", "created_at": "…", "data": {…}}`.

| Event | When | data |
| --- | --- | --- |
| `message.delivered` | An SMS from the API, the portal or a campaign is delivered | The message object (`source: "portal"` for portal traffic) |
| `message.clicked` | A tracked short link is clicked | Message + `url`, `short_url`, `clicked_at`, `device`, `os`, `browser` |
| `message.inbound` | A reply / MO to one of your numbers | `from`, `to`, `text`, `channel`, `received_at` |
| `contact.opted_out` | Unsubscribe from a list (keyword, STOP, link, form) or a number added to the blacklist | `phone`, `scope` (`group` \| `all`), `group_id`, `group_name`, `source`, `keyword`, `opted_out_at` |

```
{"id":"evt_01jb…","type":"contact.opted_out","mode":"live","created_at":"2026-10-31T10:15:00Z",
 "data":{"object":"opt_out","phone":"+573001234567","scope":"group","group_id":"6ae4daa666119","group_name":"Newsletter","source":"keyword","keyword":"baja","opted_out_at":"2026-10-31T10:15:00Z"}}
```

> STOP produces one `scope: "all"` event (blacklist) plus one `scope: "group"` event per list the number was in.

## Contacts {#contacts}

| GET | `/v1/contact-lists` | Your lists: `id`, `name`, `contacts` |
| --- | --- | --- |
| POST | `/v1/contact-lists` | `{"name": "Leads"}` |
| POST | `/v1/contacts` | Add or update (the phone is the key inside a list). `201` created, `200` updated. |
| GET | `/v1/contacts?list_id=&phone=` | Search (up to 100) |
| GET · PATCH · DELETE | `/v1/contacts/{id}` | One contact |

```
curl -X POST https://app.ogave.co/v1/contacts -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"list_id":"6ae4daa666119","phone":"+573001234567","first_name":"Ana","email":"ana@example.com","fields":{"COMPANY":"Acme"}}'
```

Rules (same as file imports): the number must be valid (international, or national with `country`); blacklisted numbers are refused (`422 opted_out`); a contact that unsubscribed is updated but never re-subscribed; `fields` keys are the field tags of the list and values must fit the field type (date `YYYY-MM-DD`, number, URL…), else `422` with `param: "fields.<TAG>"`.


---

<!-- source: https://app.ogave.co/docs/integrations/zapier -->
# Zapier

Instant triggers (no polling delay) and actions with your Ogave SMS API key.

## Set up {#setup}

1. In Zapier, add the **Ogave SMS** app and connect it with an API key from **Portal › Developers › API keys**. Use a `test_` key while you build the Zap: messages are simulated, never sent and never charged.
2. Pick a trigger. Zapier subscribes a REST hook for you (you will see it in **Portal › Integrations**) and removes it when the Zap is turned off.
3. Pick an action, map the fields, test, publish the Zap.

## Triggers {#triggers}

| Trigger | Event |
| --- | --- |
| Message Delivered | `message.delivered` |
| Link Clicked | `message.clicked` |
| New Inbound Message | `message.inbound` |
| Contact Opted Out | `contact.opted_out` |

Fields arrive flat: the event's `data` plus `event`, `event_id` and `mode`. See [Events](https://app.ogave.co/docs/integrations#events).

## Actions {#actions}

| Action | API |
| --- | --- |
| Send SMS | `POST /v1/messages` (links shortened and tracked by default; idempotent per Zap run) |
| Add or Update Contact | `POST /v1/contacts` (list chosen from a dropdown) |

## Ideas {#examples}

- New Typeform / Google Form entry → Add or Update Contact → Send SMS (welcome).
- New Inbound Message → Slack message to the sales channel.
- Contact Opted Out → update the CRM or mailing tool.
- Link Clicked → add a tag in your CRM.

## For developers {#dev}

The Zapier CLI app lives in the repository under `integrations/zapier/` (authentication, 4 hook triggers + a hidden list trigger, 2 actions). `npm install && npm test` validates it against the Zapier schema offline; `zapier push` (with a Zapier developer account) uploads it. Set `API_URL` to test against a sandbox.


---

<!-- source: https://app.ogave.co/docs/integrations/make -->
# Make

Instant triggers and actions for Make (formerly Integromat) scenarios, with your API key.

## Set up {#setup}

1. Add the **Ogave SMS** app to a scenario and create a connection with an API key (Portal › Developers › API keys; `test_` keys simulate).
2. Instant triggers create a webhook in Make; Make registers it with us (`POST /v1/hooks`, `platform: "make"`) and deletes it when you delete the webhook.

## Modules {#modules}

| Module | Type | API |
| --- | --- | --- |
| Watch Delivered Messages | Instant trigger | `message.delivered` |
| Watch Link Clicks | Instant trigger | `message.clicked` |
| Watch Inbound Messages | Instant trigger | `message.inbound` |
| Watch Opt-outs | Instant trigger | `contact.opted_out` |
| Send an SMS | Action | `POST /v1/messages` |
| Add or Update a Contact | Action | `POST /v1/contacts` |

“Choose where to start” uses `GET /v1/hooks/samples/{event}` (your latest real events).

## For developers {#dev}

The custom app definition (IML: base, connection, webhooks, RPC for the list dropdown, modules) is in `integrations/make/app.json`. Create a custom app in Make › Custom apps and paste each section into the component of the same name. Base URL: `https://app.ogave.co/v1`.


---

<!-- source: https://app.ogave.co/docs/integrations/shopify -->
# Shopify

Abandoned cart reminders, order confirmations, shipping updates with the tracking link, and OTP codes — sent from your templates, at your plan prices. Links become tracked short links on your short domain.

## Connect {#connect}

1. Portal › Integrations › Shopify: enter `your-store.myshopify.com` and click **Connect**.
2. Shopify asks you to approve read access to checkouts, orders, customers and fulfillments. You come back to the portal connected.
3. Choose the sender ID, enable the events you want and edit the texts. Start in **test mode** to see the SMS in your message log without sending.

Security: the OAuth `state` is single use, valid 10 minutes and bound to your login; Shopify's `hmac` is checked on every callback; the access token is stored encrypted. Every webhook is checked against `X-Shopify-Hmac-Sha256` (HMAC-SHA256 of the raw body with the app secret) and the shop domain; repeated deliveries are ignored. Uninstalling the app in Shopify disconnects it here.

## Events and templates {#events}

| Event | Shopify webhook | When the SMS goes out |
| --- | --- | --- |
| Abandoned cart | `checkouts/create`, `checkouts/update` | N minutes (default 60, 15–2880) after the checkout started, if no order was placed for it. Link: Shopify's recovery URL. |
| Order created | `orders/create` | Right away (also closes the checkout: no reminder) |
| Order shipped | `fulfillments/create` | Right away, with the carrier's tracking link |

Variables: `{first_name}` `{store}` `{order_number}` `{total}` `{currency}` `{cart_url}` `{tracking_url}` `{tracking_number}` `{carrier}`. One SMS per event and order, never two. Every event (sent, skipped, failed) is in the connection's log.

## OTP codes (login / checkout confirmation) {#otp}

Enable **OTP code** and add an **app proxy** (subpath `apps/sms` → `https://app.ogave.co/integrations/shopify/proxy`). Your theme then calls, from the storefront:

```
fetch('/apps/sms/otp/start', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({phone: '+573001234567'})})
// → {"verification_id": "ver_…", "status": "pending", "expires_at": "…"}
fetch('/apps/sms/otp/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({verification_id: 'ver_…', code: '123456'})})
// → {"result": "approved" | "invalid" | "expired" | "max_attempts"}
```

Shopify signs proxy requests (`signature`); we refuse unsigned ones. Codes are sent by Verify (one Verify app per store: same code rules, fraud limits, prices and logs).

## Platform setup (admins) {#admin}

Create a public app in the Shopify Partner dashboard. App URL `https://app.ogave.co/integrations/shopify/install`, redirect URL `https://app.ogave.co/integrations/shopify/callback`. Paste the client ID and secret in Admin › Settings › Integrations.


---

<!-- source: https://app.ogave.co/docs/integrations/woocommerce -->
# WooCommerce

Abandoned checkout reminders, order confirmations, shipping updates and OTP codes for WordPress + WooCommerce stores.

## Connect {#connect}

Portal › Integrations › WooCommerce: enter the store address. Then either:

- **Automatic**: also paste REST API keys (WooCommerce › Settings › Advanced › REST API, permission Read/Write). We create the two webhooks for you. Keys are stored encrypted.
- **Manual**: without keys we show a delivery URL and a secret (once). In WooCommerce › Settings › Advanced › Webhooks add two webhooks, topics **Order created** and **Order updated**, API version WP REST API v3, with that URL and secret.

Every webhook is checked against `X-WC-Webhook-Signature` (base64 HMAC-SHA256 of the raw body with the secret). The test ping WooCommerce sends when a webhook is saved is answered with 200.

## Events {#events}

| Order status | Event |
| --- | --- |
| `pending`, `checkout-draft`, `failed` | Checkout not completed → abandoned checkout SMS after N minutes with the order's payment link |
| `processing`, `on-hold` | Order created |
| `completed` | Order shipped. Tracking from the *Shipment Tracking* plugin (`_wc_shipment_tracking_items`): link, else the tracking number |
| `cancelled`, `refunded` | Closes the checkout (no reminder) |

National numbers are read with the billing country. Same templates, variables, test mode and log as [Shopify](https://app.ogave.co/docs/integrations/shopify#events).

## OTP codes {#otp}

From your server (e.g. a login-by-SMS plugin), sign the JSON body with the store secret:

```
$body = json_encode(['phone' => '+573001234567']);
$sig = base64_encode(hash_hmac('sha256', $body, $secret, true));
POST https://app.ogave.co/integrations/woocommerce/otp/{connection}/start   (header X-Webhook-Signature: $sig)
POST https://app.ogave.co/integrations/woocommerce/otp/{connection}/check   {"verification_id": "ver_…", "code": "123456"}
```


---

<!-- source: https://app.ogave.co/docs/integrations/tiendanube -->
# Tiendanube / Nuvemshop

Abandoned cart reminders and order created, paid and shipped notices for Tiendanube and Nuvemshop stores.

## Connect {#connect}

Portal › Integrations › Tiendanube › **Connect Tiendanube**: sign in to your store and approve the app. We create the webhooks `order/created`, `order/paid`, `order/fulfilled` and `app/uninstalled`. Every notice is checked with `x-linkedstore-hmac-sha256` (HMAC-SHA256 of the body with the app secret) and the order is read from your store's API.

**Abandoned carts** (Tiendanube does not send them) are read from `/checkouts` every few minutes, with their recovery link.

Uninstalling the app in Tiendanube disconnects the store and deletes the token.

| In the store | Event |
| --- | --- |
| Order placed | Order created |
| Payment `paid` | Order paid |
| Shipping `shipped` / `fulfilled` | Order shipped (tracking number and link when the store has them) |
| Unfinished checkout | Abandoned cart |

## Messages {#messages}

Portal › Integrations › the store: turn each event on and write its text (it starts in your language). Variables: `{first_name}` `{store}` `{order_number}` `{total}` `{currency}` `{items}` `{cart_url}` `{tracking_url}` `{tracking_number}` `{carrier}`. Links become tracked short links.

- **Abandoned cart**: N minutes (15–2880, default 60) after the cart last changed, if no order was placed. Marketing: your quiet hours and frequency caps apply.
- **Order created / paid / shipped**: transactional, once per order (even when the store repeats the notice or the order changes several times).
- Blacklisted and opted-out numbers never get SMS. **Test** mode: simulated SMS, never charged.

## Polling {#polling}

We read the orders and carts changed since the last read every 5 minutes (choose 5, 10, 15, 30 or 60). A failed read is retried over the same window: nothing is lost or sent twice. Only changes after you connected the store are read: older orders never get an SMS. We keep only what the SMS needs (phone, first name, total, cart link); every event is logged on the store page.

**Test connection** reads the store with the saved credentials and sends nothing. **Disconnect** deletes the credentials.


---

<!-- source: https://app.ogave.co/docs/integrations/vtex -->
# VTEX

Order created, paid and invoiced/shipped notices and abandoned cart reminders for VTEX stores.

## Connect {#connect}

In VTEX: **Account settings › Account › Security › API Keys** (or License Manager) create an app key with the *OMS - Full access* role (or order read access). In the portal: Integrations › VTEX: account name (the part before `.myvtex.com`), **App key** and **App token**. On connect we create the VTEX **order hook** (statuses `order-created`, `payment-approved`, `invoiced`) with our own token in the `X-Store-Token` header; orders are also read every few minutes.

VTEX allows one hook per app key: use an app key only for us.

## Abandoned carts {#carts}

VTEX has no abandoned cart API. Send them from a **Master Data trigger** (cart/customer entity, action "Send an HTTP request") or any script to the cart URL shown on the store page, with the `X-Store-Token` header and this JSON body:

```
POST https://app.ogave.co/integrations/vtex/carts/{connection}
{"cart_id": "orderFormId", "phone": "+573001234567", "first_name": "Ana", "cart_url": "https://shop.com/checkout/?orderFormId=…",
 "total": 129900, "currency": "COP", "items": [{"name": "T-shirt", "quantity": 2}]}
```

When the order of that cart (`orderFormId`) or of the same phone arrives, the reminder is cancelled.

## Messages {#messages}

Portal › Integrations › the store: turn each event on and write its text (it starts in your language). Variables: `{first_name}` `{store}` `{order_number}` `{total}` `{currency}` `{items}` `{cart_url}` `{tracking_url}` `{tracking_number}` `{carrier}`. Links become tracked short links.

- **Abandoned cart**: N minutes (15–2880, default 60) after the cart last changed, if no order was placed. Marketing: your quiet hours and frequency caps apply.
- **Order created / paid / shipped**: transactional, once per order (even when the store repeats the notice or the order changes several times).
- Blacklisted and opted-out numbers never get SMS. **Test** mode: simulated SMS, never charged.

## Polling {#polling}

We read the orders and carts changed since the last read every 5 minutes (choose 5, 10, 15, 30 or 60). A failed read is retried over the same window: nothing is lost or sent twice. Only changes after you connected the store are read: older orders never get an SMS. We keep only what the SMS needs (phone, first name, total, cart link); every event is logged on the store page.

**Test connection** reads the store with the saved credentials and sends nothing. **Disconnect** deletes the credentials.


---

<!-- source: https://app.ogave.co/docs/integrations/prestashop -->
# PrestaShop

Abandoned cart and order created, paid and shipped for PrestaShop 1.7 and 8. PrestaShop has no webhooks: we read the store every few minutes.

## Connect {#connect}

1. PrestaShop › **Advanced parameters › Webservice**: enable the webservice and **Add new key**.
2. **GET** permission on: `orders`, `carts`, `addresses`, `countries`, `currencies`.
3. In the portal: Integrations › PrestaShop: store address and the key (32 characters). The store must be on a public (https) address.

On the store page set its **time zone** (International › Localization): webservice dates are in store time.

| In the store | Event |
| --- | --- |
| New order | Order created |
| State Payment accepted (2), Remote payment accepted (11) or later | Order paid |
| State Shipped (4) / Delivered (5) | Order shipped (with the order's tracking number) |
| Cart without an order N minutes after its last change | Abandoned cart (link: the store's cart page) |

## Messages {#messages}

Portal › Integrations › the store: turn each event on and write its text (it starts in your language). Variables: `{first_name}` `{store}` `{order_number}` `{total}` `{currency}` `{items}` `{cart_url}` `{tracking_url}` `{tracking_number}` `{carrier}`. Links become tracked short links.

- **Abandoned cart**: N minutes (15–2880, default 60) after the cart last changed, if no order was placed. Marketing: your quiet hours and frequency caps apply.
- **Order created / paid / shipped**: transactional, once per order (even when the store repeats the notice or the order changes several times).
- Blacklisted and opted-out numbers never get SMS. **Test** mode: simulated SMS, never charged.

## Polling {#polling}

We read the orders and carts changed since the last read every 5 minutes (choose 5, 10, 15, 30 or 60). A failed read is retried over the same window: nothing is lost or sent twice. Only changes after you connected the store are read: older orders never get an SMS. We keep only what the SMS needs (phone, first name, total, cart link); every event is logged on the store page.

**Test connection** reads the store with the saved credentials and sends nothing. **Disconnect** deletes the credentials.


---

<!-- source: https://app.ogave.co/docs/integrations/magento -->
# Magento 2 / Adobe Commerce

Abandoned cart and order created, paid and shipped for Magento 2 and Adobe Commerce, reading the store every few minutes (REST API).

## Connect {#connect}

1. Magento › **System › Extensions › Integrations › Add New Integration**. Resources: *Sales › Operations › Orders* (view), *Shipments* and *Carts* (view).
2. **Activate** it and copy the **Access token**.
3. In the portal: Integrations › Magento: store address and the token.

On Magento 2.4.4+ integration tokens used as bearer tokens need `Stores › Configuration › Services › OAuth › Allow OAuth Access Tokens to be used as standalone Bearer tokens = Yes`.

| In the store | Event |
| --- | --- |
| New order | Order created |
| Status `processing` or paid | Order paid |
| Status `complete` | Order shipped (shipment tracking) |
| Active cart with a phone and no order | Abandoned cart (link: `/checkout/cart/`; the shopper signs in to see it) |

## Messages {#messages}

Portal › Integrations › the store: turn each event on and write its text (it starts in your language). Variables: `{first_name}` `{store}` `{order_number}` `{total}` `{currency}` `{items}` `{cart_url}` `{tracking_url}` `{tracking_number}` `{carrier}`. Links become tracked short links.

- **Abandoned cart**: N minutes (15–2880, default 60) after the cart last changed, if no order was placed. Marketing: your quiet hours and frequency caps apply.
- **Order created / paid / shipped**: transactional, once per order (even when the store repeats the notice or the order changes several times).
- Blacklisted and opted-out numbers never get SMS. **Test** mode: simulated SMS, never charged.

## Polling {#polling}

We read the orders and carts changed since the last read every 5 minutes (choose 5, 10, 15, 30 or 60). A failed read is retried over the same window: nothing is lost or sent twice. Only changes after you connected the store are read: older orders never get an SMS. We keep only what the SMS needs (phone, first name, total, cart link); every event is logged on the store page.

**Test connection** reads the store with the saved credentials and sends nothing. **Disconnect** deletes the credentials.


---

<!-- source: https://app.ogave.co/docs/integrations/jumpseller -->
# Jumpseller

Abandoned checkout and order created, paid and shipped for Jumpseller stores.

## Connect {#connect}

1. Jumpseller › **Settings › API**: copy the API **Login** and **Auth token**.
2. Jumpseller › **Settings › Notifications › Webhooks**: copy the **Hooks token** (we use it to check every notice).
3. In the portal: Integrations › Jumpseller: the three values.

On connect we create the webhooks `order_created`, `order_paid` and `order_shipped`; every notice is checked with `Jumpseller-Hmac-Sha256` (base64 HMAC-SHA256 of the body with the hooks token). Abandoned checkouts (status *Abandoned*, with their recovery link) are read every few minutes.

## Messages {#messages}

Portal › Integrations › the store: turn each event on and write its text (it starts in your language). Variables: `{first_name}` `{store}` `{order_number}` `{total}` `{currency}` `{items}` `{cart_url}` `{tracking_url}` `{tracking_number}` `{carrier}`. Links become tracked short links.

- **Abandoned cart**: N minutes (15–2880, default 60) after the cart last changed, if no order was placed. Marketing: your quiet hours and frequency caps apply.
- **Order created / paid / shipped**: transactional, once per order (even when the store repeats the notice or the order changes several times).
- Blacklisted and opted-out numbers never get SMS. **Test** mode: simulated SMS, never charged.

## Polling {#polling}

We read the orders and carts changed since the last read every 5 minutes (choose 5, 10, 15, 30 or 60). A failed read is retried over the same window: nothing is lost or sent twice. Only changes after you connected the store are read: older orders never get an SMS. We keep only what the SMS needs (phone, first name, total, cart link); every event is logged on the store page.

**Test connection** reads the store with the saved credentials and sends nothing. **Disconnect** deletes the credentials.


---

<!-- source: https://app.ogave.co/docs/integrations/hubspot -->
# HubSpot

Keep a contact list and your HubSpot contacts in sync, and see every SMS on the HubSpot contact timeline.

## Connect {#connect}

1. Portal › Integrations › HubSpot › **Connect HubSpot**, approve contacts read / write.
2. Choose the contact list, the direction (both ways, HubSpot → contacts, contacts → HubSpot), the schedule (hourly, every 6 hours, daily or manual) and the country for national numbers.
3. **Sync now** runs it immediately; results are in the log.

## Mapping {#mapping}

| HubSpot | Contact field |
| --- | --- |
| `mobilephone`, else `phone` | Phone (the key on both sides, E.164) |
| `firstname` / `lastname` / `email` | FIRST_NAME / LAST_NAME / EMAIL |

Pull reads contacts changed since the last pull (CRM search by `lastmodifieddate`); HubSpot contacts without a valid phone and blacklisted numbers are skipped. Push sends contacts of the list changed since their last sync (batch create / update). Contacts that came from HubSpot are not sent back unchanged.

## SMS on the timeline {#timeline}

SMS sent to and replies received from synced contacts are logged as HubSpot **Communications** (channel SMS) on the contact, every minute. Only messages after the connection was made are logged.

## Platform setup (admins) {#admin}

Public app in a HubSpot developer account, redirect URL `https://app.ogave.co/integrations/hubspot/callback`, scopes `crm.objects.contacts.read crm.objects.contacts.write`. Client ID and secret in Admin › Settings › Integrations. Tokens are refreshed automatically and stored encrypted.


---

<!-- source: https://app.ogave.co/docs/integrations/google-sheets -->
# Google Sheets

Use a sheet as the source of a contact list: new rows are added and changed rows update the contacts, on a schedule.

## Connect {#connect}

1. In Google Sheets: **Share › Anyone with the link › Viewer** (or File › Share › Publish to the web as CSV). No Google account needed on our side.
2. Portal › Integrations › Google Sheets: paste the link (the tab of the link is used: `#gid=…`).
3. Choose the contact list, map the columns (one must be the phone), the schedule and the country for national numbers. Save, then **Sync now**.

Private sheet? Click **Connect Google** on the connection: read-only access to Sheets (OAuth, token stored encrypted).

## How the sync works {#rules}

- Each sync reads the whole sheet as CSV and runs the normal contact importer: invalid numbers, duplicates, blacklisted and opted-out numbers are skipped and counted (rejects file in Contacts › Imports), existing contacts are updated.
- The mapping is kept by **column name**: moving columns is fine; renaming the phone column stops the sync with a clear error.
- Rows removed from the sheet are not deleted from the list.
- Up to 20 MB per sheet. A sync does not start while the previous one is still importing.


---

<!-- source: https://app.ogave.co/docs/integrations/zoho -->
# Zoho CRM

Keep a contact list and your Zoho CRM Contacts or Leads in sync both ways, respect opt-outs, keep every SMS and every reply as a note on the Zoho record, and send SMS from Zoho workflow rules or buttons.

## Connect {#connect}

1. Portal › Integrations › Zoho CRM: choose the data centre of your Zoho account (zoho.com, .eu, .in, .com.au, .jp, .ca) and press **Connect Zoho CRM**; approve contacts, leads, notes and organization access.
2. Choose the contact list, the module (Contacts or Leads), optionally one Zoho custom view (its number), the direction, the schedule and the country for national numbers.
3. **Sync now** runs it immediately; results are in the log.

## Fields {#mapping}

| Zoho | Contact |
| --- | --- |
| `Mobile`, else `Phone` | Phone (the key on both sides, E.164) |
| `First_Name` / `Last_Name` / `Email` | FIRST_NAME / LAST_NAME / EMAIL |
| `SMS_Opt_Out` (checkbox; name configurable) | Ticked in Zoho → the contact is unsubscribed from the list. An opt-out here (STOP, form) ticks it in Zoho. |

Create the checkbox in Zoho (Setup › Modules › Contacts › field "Checkbox") and use its API name. Records without a valid phone and blacklisted numbers are skipped; someone who opted out is never created in Zoho.

## SMS in Zoho {#notes}

Every SMS sent to a synced contact is saved as a note once its final status arrives (delivered, undelivered…), and every reply as "SMS received". The first run starts now: older history is not copied.

## Send SMS from Zoho {#send}

The connection page shows your **send URL** (keep it secret). In Zoho: Setup › Automation › Actions › Webhooks › new webhook, method POST, that URL, and these parameters:

| Parameter | Value |
| --- | --- |
| `to` | `${Contacts.Mobile}` (required) |
| `text` | the text, or |
| `template` | id of one of your approved SMS templates; its {placeholders} are filled from the other parameters, e.g. `first_name=${Contacts.First Name}` |
| `record_id` | `${Contacts.Contact Id}` (kept in the connection log) |
| `ref` | optional: the same ref is sent once (Zoho retries) |
| `from`, `category` | optional: approved sender; marketing applies quiet hours and caps |

```
curl -X POST "https://app.ogave.co/integrations/zoho/actions/<id>?token=<token>" -d to=+573001234567 -d template=<template id> -d first_name=Ana -d ref=deal-42-won
{"status":"queued","message_id":"msg_01jb…"}
```

Attach it to a workflow rule (e.g. "deal won", "appointment created") or to a record button. Errors: 401 token, 422 data (with code and param), 402 balance.


---

<!-- source: https://app.ogave.co/docs/integrations/okta -->
# Okta

Okta sends its one-time codes (phone enrollment and verification, SMS MFA) through our platform with a **Telephony Inline Hook**: no code to write. Okta makes and checks the code; we deliver it with your Verify app (sender, template, language and fraud protection) and it appears on your Verify page tagged `okta`.

## Set up {#setup}

1. Portal › Developers › API keys: create a **live** key with limited access and only the `verify` permission (you can also limit it to Okta's IP addresses).
2. Portal › Verify: copy the id of the app to use (`app_…`).
3. Okta Admin Console › Workflow › Inline Hooks › Add Inline Hook › **Telephony**. URL: `https://app.ogave.co/v1/verify/connectors/okta?app_id=app_…`. Authentication field: `Authorization`; Authentication secret: `Bearer live_…` (your key).
4. Save, then use **Preview** with a test number: it must answer `SUCCESSFUL`. Activate the hook.

## Responses {#responses}

Success: `commands[0].value[0].status = "SUCCESSFUL"` with `transactionId` = the verification id (`ver_…`). When we cannot send (premium number, new country over its limit, rate limits, balance…) we answer `200` with an `error` object and Okta falls back to its own provider. Without a key or without the `verify` permission: `401` / `403`.

## Notes {#notes}

- Channels: `SMS`; `Voice call` uses the voice channel when your account has it.
- Language: Okta's `locale` when a template exists in that language, otherwise the app's.
- Okta checks the code: these verifications are not part of the Verify conversion figures or the approval-rate detectors; rate limits, the premium number list and new-country protection do apply.

```
POST https://app.ogave.co/v1/verify/connectors/okta?app_id=app_…
Authorization: Bearer live_…

{"eventType": "com.okta.telephony.provider", "data": {"messageProfile": {"phoneNumber": "+573001234567", "otpCode": "418276",
  "deliveryChannel": "SMS", "locale": "EN-US", "otpExpires": "2026-11-03T16:20:45.000Z"}}}

200 {"commands": [{"type": "com.okta.telephony.action", "value": [{"status": "SUCCESSFUL", "provider": "Ogave SMS",
  "transactionId": "ver_01…", "transactionMetadata": "sms"}]}]}
```


---

<!-- source: https://app.ogave.co/docs/integrations/auth0 -->
# Auth0

Auth0 sends its MFA, enrollment and blocked-account SMS through our platform with a **custom phone provider** (an Action you paste as is). Auth0 makes and checks the code; we deliver it with your Verify app (sender, template, language and fraud protection) and it appears on your Verify page tagged `auth0`.

## Set up {#setup}

1. Portal › Developers › API keys: create a **live** key with limited access and only the `verify` permission.
2. Auth0 Dashboard › Branding › Phone Provider › **Custom**. In the Action editor paste the code below and add the secret `API_KEY` with your key.
3. Replace `app_…` with your Verify app id, deploy, and use **Send test message**.

## Responses {#responses}

`201` with the verification id. When we cannot send (premium number, new country over its limit, rate limits, balance…) we answer with the API error (`4xx`) and the Action throws it: Auth0 logs the failure.

The body of the older "Send Phone Message" Action (`event.message_options`) is accepted too.

```
exports.onExecuteCustomPhoneProvider = async (event, api) => {
  const res = await fetch("https://app.ogave.co/v1/verify/connectors/auth0?app_id=app_…", {
    method: "POST",
    headers: { "Authorization": `Bearer ${event.secrets.API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(event.notification),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.error ? `${body.error.code}: ${body.error.message}` : `HTTP ${res.status}`);
  }
};
```
