API Guide
This guide covers everything you need to send SMS messages through this gateway, check on their delivery, and receive webhook notifications when their status changes.
Contents
- Getting an API key
- Authentication
- Sending a message
- Checking message status
- Listing your messages
- Cancelling a message
- Your devices
- Install the Android app
- Use with AI agents (MCP)
- Webhooks
- Idempotency
- Message status lifecycle
- Rate limits
- Destination region allowlist
- Error responses
Getting an API key
Your API key is issued to you by whoever administers this gateway — ask them for one. It is shown in full exactly once, when it is created or reissued, so copy it somewhere safe right away. If you lose it, ask for a replacement; the old key is invalidated the moment a new one is issued.
Authentication
Every request below (aside from this page) needs an Authorization
header:
Authorization: Bearer YOUR_API_KEY
A missing header, a malformed one, or a key that is unknown or has been
deactivated, all return the same 401:
{ "error": { "code": "unauthorized", "message": "Invalid API key." } }
Sending a message
curl -X POST https://sms.vitrix.com.ar/api/v1/messages \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "011 15 1234 5678",
"body": "Your order #42 has shipped.",
"client_ref": "order-42",
"callback_url": "https://yourapp.example.com/webhooks/sms"
}'
Response — 202 Accepted:
{ "id": "01k2yb1x1z8h1n8jrq6z9v9c1a", "status": "pending", "parts_count": 1 }
to is normalized automatically. A number with no leading + country
code is treated as an Argentine (AR) local number, so 011 15 1234 5678
is accepted as-is; international numbers should be given in full E.164
form (e.g. +54 9 11 1234 5678). A number that cannot be parsed returns a
422 validation error on to.
Fields you can send:
| Field | Required | Notes |
|---|---|---|
to |
yes | Destination number, up to 32 characters. |
body |
yes | Message text, up to 2000 characters. |
client_ref |
no | Your own idempotency key — see Idempotency. |
callback_url |
no | Where to POST delivery-status webhooks for this message. |
priority |
no | 0–9, higher is sent sooner. |
expires_in |
no | Seconds before an unclaimed message expires; defaults to 24 hours. |
A minimal PHP example, using Laravel's Http facade:
<?php
use Illuminate\Support\Facades\Http;
$response = Http::withToken('YOUR_API_KEY')
->baseUrl('https://sms.vitrix.com.ar/api/v1')
->post('/messages', [
'to' => $order->customer->phone,
'body' => "Your order #{$order->id} has shipped.",
'client_ref' => "order-{$order->id}",
]);
$response->throw();
$message = $response->json();
Checking message status
curl https://sms.vitrix.com.ar/api/v1/messages/01k2yb1x1z8h1n8jrq6z9v9c1a \
-H "Authorization: Bearer YOUR_API_KEY"
Returns the full message record, including its status, timestamps and
delivery events. A message that does not exist, or belongs to another
account, both return a plain 404 — never a 403 — so you cannot use
this endpoint to probe whether an id belongs to someone else.
Prefer a webhook over polling whenever you can receive inbound HTTP — it tells you the moment something changes instead of you asking repeatedly.
Listing your messages
curl "https://sms.vitrix.com.ar/api/v1/messages?status=failed&per_page=25" \
-H "Authorization: Bearer YOUR_API_KEY"
Returns a paginated list of your own messages, most recent first.
Optional query parameters: status (one of the values in
Message status lifecycle), from / to
(date filters on creation time), and per_page (up to 100, default 25).
Cancelling a message
curl -X POST https://sms.vitrix.com.ar/api/v1/messages/01k2yb1x1z8h1n8jrq6z9v9c1a/cancel \
-H "Authorization: Bearer YOUR_API_KEY"
Only works while the message is still pending — once it has been
picked up for delivery it can no longer be cancelled, and this returns a
422 explaining why.
Your devices
curl https://sms.vitrix.com.ar/api/v1/devices \
-H "Authorization: Bearer YOUR_API_KEY"
Lists the phones assigned to your account, each with a battery level,
signal/SIM info, last-seen time and an online flag. This only ever
shows your own devices — a shared gateway does not disclose anyone
else's fleet size or device names.
A 202 from sending a message means the message
was accepted, not that a phone is ready to send it. If none of your
devices are online, a message stays pending until it expires. Checking
GET /devices alongside a message's status is how you tell "no phone
available" apart from "the phone is just busy".
Install the Android app
The phones listed under Your devices carry your messages by running this gateway's Android app, side-loaded rather than installed from a store. Open this address on the phone to download it: https://sms.vitrix.com.ar/downloads/android
Use with AI agents (MCP)
An AI coding agent (Claude Code, Claude Desktop, Codex) can call this gateway directly, using this same API key, instead of you writing the HTTP calls above by hand — it connects to an MCP (Model Context Protocol) server that exposes a handful of tools scoped to your account.
Endpoint: https://sms.vitrix.com.ar/mcp
Authentication: the same header as everywhere else in this guide:
Authorization: Bearer YOUR_API_KEY
Claude Code
claude mcp add --transport http smsgateway https://sms.vitrix.com.ar/mcp \
--header "Authorization: Bearer YOUR_API_KEY"
Claude Desktop / claude.ai
The built-in "Add custom connector" option currently expects OAuth, not a
plain API key, so connect through the community
mcp-remote bridge instead —
add this to claude_desktop_config.json:
{
"mcpServers": {
"smsgateway": {
"command": "npx",
"args": [
"mcp-remote",
"https://sms.vitrix.com.ar/mcp",
"--header",
"Authorization:${SMSGATEWAY_AUTH}"
],
"env": { "SMSGATEWAY_AUTH": "Bearer YOUR_API_KEY" }
}
}
}
Codex CLI
Add a server entry to ~/.codex/config.toml (or a project's own
.codex/config.toml):
[mcp_servers.smsgateway]
url = "https://sms.vitrix.com.ar/mcp"
bearer_token_env_var = "SMSGATEWAY_API_KEY"
Then export the key in the environment Codex runs in — Codex reads the token from this variable, never from the config file itself:
export SMSGATEWAY_API_KEY=YOUR_API_KEY
Tools
| Tool | Does |
|---|---|
send_sms |
Send a message — to, body, optional idempotency_key. |
get_message |
Read one message's full status and history by id. |
list_messages |
List your messages, filterable by status/from/to, paginated. |
cancel_message |
Cancel a still-pending message by id. |
list_devices |
List your paired phones and whether each is currently online. |
Every tool is scoped to your account exactly like the REST endpoints above: your key can never see or change another account's messages or devices. Registering or removing a device stays admin/panel-only.
Webhooks
Set a callback_url on a message (or ask your gateway administrator to
set a default one for your account) and the gateway will POST a
notification to it whenever that message transitions to sent,
delivered, or failed.
Signature header — every webhook request carries an X-Signature
header shaped like this:
X-Signature: t=<unix timestamp>,v1=<hex hmac_sha256("{timestamp}.{rawBody}", secret)>
The timestamp is signed together with the raw request body, so a captured signature cannot later be replayed against a different payload, and — if you also check that the timestamp is recent — cannot be replayed at all.
Payload shape:
{
"event": "message.sent",
"event_id": "01k2ybz9c1a1z8h1n8jrq6z9v9",
"message": {
"id": "01k2yb1x1z8h1n8jrq6z9v9c1a",
"status": "sent",
"to_e164": "+5491112345678",
"client_ref": "order-42",
"sent_at": "2026-08-17T12:00:05+00:00",
"delivered_at": null,
"failed_at": null,
"error_code": null,
"error_message": null
}
}
event is one of message.sent, message.delivered, message.failed.
event_id stays the same across every retry of the same logical event —
use it to de-duplicate, since a delivery attempt that does not succeed is
retried several times with increasing backoff.
Verifying a webhook in PHP — paste this into your receiving controller:
<?php
function verifyGatewaySignature(string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool
{
$parts = [];
foreach (explode(',', $signatureHeader) as $segment) {
[$key, $value] = array_pad(explode('=', trim($segment), 2), 2, null);
if ($key !== null && $value !== null) {
$parts[$key] = $value;
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
$timestamp = (int) $parts['t'];
if (abs(time() - $timestamp) > $toleranceSeconds) {
return false; // too old, or clock-skewed into the future
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// $rawBody must be the raw, unparsed request body — re-encoding the
// parsed JSON before verifying can change the byte string and break
// the check.
Your callback_url must be a plain http/https URL — localhost and
private/loopback addresses are rejected up front.
Idempotency
Pass a client_ref (any string, unique to your account, up to 191
characters — your own order or job id works well) when sending a
message. Retrying the exact same to/body under the same client_ref
returns the original message (200, not 202) instead of sending a
duplicate. Reusing a client_ref with a different to or body is a
genuine conflict and returns 409 client_ref_conflict.
Old messages are not kept forever — once a message has aged out and been
removed, reusing its client_ref sends a new message instead of
returning the original, so don't rely on a very old client_ref staying
idempotent indefinitely.
Message status lifecycle
| Status | Meaning |
|---|---|
pending |
Accepted, not yet picked up for delivery. |
dispatched |
A phone has claimed it and is expected to send it. |
sent |
Handed off to the carrier successfully. |
delivered |
Confirmed delivered to the handset (terminal). |
failed |
Sending or delivery failed (terminal). |
expired |
Nobody claimed it before it expired (terminal). |
cancelled |
Cancelled through the API while still pending (terminal). |
pending → dispatched → sent → delivered
↘ failed
pending → expired
pending → cancelled
Rate limits
Requests are rate-limited per API key, separately from any other
account's traffic. Exceeding the limit returns a plain 429 with a
Retry-After header telling you how long to wait. If you are sending in
bulk, spread requests out rather than firing them all at once.
Destination region allowlist
By default this gateway accepts destinations from any region. If your
gateway administrator has restricted it to a set of countries, a
destination number that normalizes outside that list is rejected with a
422 instead of being queued — ask your administrator which regions are
allowed if you hit this.
Error responses
Errors share one shape:
{ "error": { "code": "some_code", "message": "Human-readable explanation." } }
| Status | When |
|---|---|
401 |
Missing, malformed, unknown or deactivated API key. |
404 |
Message or device not found, or not yours. |
409 |
client_ref reused with a different to/body. |
422 |
Validation failed, an invalid transition was requested, or the destination region is not allowed. |
429 |
Rate limit exceeded. |