SMS Gateway

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

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.