# Calisero transactional SMS API (version 1.0.16)

API documentation for sending and managing transactional SMS messages via [Calisero](https://calisero.ro).

The API accepts HTTPS connections with TLS 1.2 or TLS 1.3 only. From 1 November 2026, a client that offers only TLS 1.0 or TLS 1.1 is refused during the TLS handshake: it gets no HTTP response, and nothing is sent or billed. Call `https://` directly: a request to `http://` is redirected, and most HTTP clients turn a redirected `POST` into a `GET` without its body.

- Base URL: `https://rest.calisero.ro/api/v1`
- Authentication: send the API key as a bearer token, `Authorization: Bearer <API key>`, on every request.
- Requests and responses are JSON: send `Accept: application/json`, and `Content-Type: application/json` with a body.

## Daily sending limit

Every account has its own daily sending limit: how many messages it can send in a day. A new account starts with a limit of 200 messages a day and keeps it until we change it. To raise it, write to contact@calisero.ro with your account and the daily volume you expect.

What counts, once per message whatever its number of parts:

- every real message the account creates, through `POST /messages` or the dashboard;
- every OTP code sent with `POST /verifications`;
- a scheduled message, on the day it is created, not on the day it is sent.

Test messages, from a sandbox account or with a sandbox API key, never count and are never refused.

The day is the calendar day in Romania: the limit resets at midnight, Romania time (Europe/Bucharest), whatever time zone you send from.

While the account has a limit, every message or verification created with `POST /messages` or `POST /verifications` is answered with two headers:

| Header | Description | Example |
| --- | --- | --- |
| `X-Daily-Limit` | The account's daily limit. | `200` |
| `X-Daily-Remaining` | How many messages the account can still send today, after this one. | `199` |

Once the limit is reached, both endpoints refuse new messages until midnight, Romania time, with `429 Too Many Requests`; nothing is sent and nothing is billed:

```json
{
    "message": "This account can send at most 200 messages a day. The limit resets at midnight, Romania time (2026-10-01T00:00:00+03:00). Contact us to raise it.",
    "code": "daily_limit_exceeded",
    "daily_limit": 200,
    "daily_remaining": 0,
    "resets_at": "2026-10-01T00:00:00+03:00",
    "trace_id": "9b80eef1-49d4-4502-85a8-febb68cc11a7"
}
```

The refusal carries `Retry-After` (the seconds until midnight, Romania time), `X-Daily-Limit` and `X-Daily-Remaining: 0`. Send again after `resets_at`, or ask us to raise the limit.

The limit, what is left of it and what the account used of it today are also in `GET /accounts/{accountId}` (`daily_limit`, `daily_remaining`, `sent_today`) and in every delivery status callback (`dailyLimit`, `dailyRemaining`, `sentToday`), as they stood when it was sent.

The request rate limit is another `429`: each API user may make 240 requests a minute, on every endpoint. Every answer carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`; above the rate, the API answers `429` with `{"message": "Too Many Attempts.", "trace_id": "…"}` and no `code`. Tell the two apart by the body's `code`:

|  | Daily sending limit | Request rate limit |
| --- | --- | --- |
| Body | `{"code": "daily_limit_exceeded", …}` | `{"message": "Too Many Attempts.", "trace_id": "…"}` |
| Headers | `Retry-After`, `X-Daily-Limit`, `X-Daily-Remaining: 0` | `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, `X-RateLimit-Reset` (a Unix timestamp) |
| Send again | after midnight, Romania time | after `Retry-After` seconds |

## Trace ID

Every API answer carries an `X-Trace-Id` header: the id of that request. The messages it creates, their delivery reports and the callbacks they lead to carry the same id.

```http
HTTP/1.1 201 Created
X-Trace-Id: 9b80eef1-49d4-4502-85a8-febb68cc11a7
```

An error (any `4xx` or `5xx` answer) also has it in its body, as `trace_id`: no message was created, so there is no message id to look for.

```json
{
    "message": "Resource not found!",
    "trace_id": "9b80eef1-49d4-4502-85a8-febb68cc11a7"
}
```

Keep it in your logs. In the dashboard, **Developers → Debug** shows everything that happened to the request: the API call, the messages, their delivery and the callbacks; the id of a message, a verification or a callback works there too. The trace id is also on each message, API log and callback in the dashboard, and quoting it lets us find the request at once.

## Accounts

### GET /accounts/{accountId}

Retrieve account information

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `accountId` | path | string (uuid) | yes | The UUID of the account |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/accounts/{accountId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` Account details

```json
{
    "data": {
        "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
        "code": "ABC1234",
        "name": "My Company",
        "description": "Company description",
        "fiscal_code": "RO12345678",
        "registry_number": "J40/123456/2023",
        "iban": "RO49AAAA1B31007593840000",
        "city": "Bucharest",
        "state": "Bucharest",
        "country": "RO",
        "address": "Main Street 123",
        "postal_code": 123456,
        "email": "contact@company.com",
        "phone": "+40721***456",
        "contact_person": "John Doe",
        "credit": 100.5,
        "status": "active",
        "sandbox": false,
        "daily_limit": 1000,
        "daily_remaining": 873,
        "sent_today": 127,
        "created_at": "2024-09-20T12:48:55.000000Z"
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `403` The API key is valid but not allowed to perform this action. Body: `{"message":"You don't have permission to access this resource."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

## Messages

### POST /messages

Create a new message

Sends an SMS now, or at `schedule_at`.

**Daily sending limit.** Every account has its own daily limit; a new account starts with 200 messages a day and keeps it until we raise it on request. Each real message the account creates counts once, whatever its number of parts: the OTP codes of `POST /verifications` too, and a scheduled message on the day it is created. Test messages (a sandbox account or a sandbox API key) never count and are never refused. The day ends at midnight, Romania time (Europe/Bucharest). While the account has a limit, the answer to a created message carries `X-Daily-Limit` and `X-Daily-Remaining` (what is left today, after this message). Once the limit is reached the answer is `429` with `"code": "daily_limit_exceeded"`, `Retry-After` and `X-Daily-Remaining: 0` until midnight, Romania time: nothing is sent and nothing is billed. See [Daily sending limit](https://docs.calisero.ro/#daily-limit).

**Request rate limit.** Separately, each API user may make 240 requests a minute. Every answer carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`; above the rate the answer is `429` with `{"message": "Too Many Attempts.", "trace_id": "…"}`, no `code`, plus `Retry-After` and `X-RateLimit-Reset`.

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recipient` | string | yes | Recipient's phone number (in E.164 format) |
| `body` | string | yes | Text body of the message |
| `visible_body` | string | no | Visible body for display purposes |
| `validity` | integer | no | Message validity period in hours |
| `schedule_at` | string (date-time) | no | Scheduled send time in 'YYYY-MM-DD HH:MM:SS' format |
| `callback_url` | string (uri) | no | Optional callback URL to receive delivery reports. It must be an http or https address on the public Internet: private, loopback and link-local addresses are refused, and redirects are not followed. |
| `sender` | string | no | Custom sender ID. It must be a sender already approved for your account (Messages > Senders in the dashboard), written exactly as approved: the match is case-sensitive. A sender that is unknown, pending, rejected or not approved yet is refused with 422 ("The sender is invalid or is not approved.") and nothing is sent. Leave the field out, or send null, to use the default sender. CALISERO in the examples is a placeholder, not a sender you can use. |
| `shorten_urls` | boolean | no | If true, URLs inside the body are shortened and returned in the response |

Example request:

```bash
curl -X POST 'https://rest.calisero.ro/api/v1/messages' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "recipient": "40752******",
    "body": "Test message",
    "schedule_at": "2024-11-16 13:21:00",
    "callback_url": "https://yoursite.com/your-callback-url",
    "sender": "CALISERO",
    "validity": 72,
    "shorten_urls": true
}'
```

Responses:

- `201` Message created successfully

```json
{
    "data": {
        "id": "9e2574e8-3615-4090-9b5a-0fc812079da8",
        "recipient": "40752******",
        "body": "Test message!",
        "parts": 1,
        "created_at": "2025-02-06T10:18:43.000000Z",
        "scheduled_at": "2025-02-06T10:18:43.000000Z",
        "sent_at": null,
        "delivered_at": null,
        "callback_url": "https://yoursite.com/your-callback-url",
        "status": "scheduled",
        "sender": "CALISERO",
        "shortened_urls": [
            {
                "id": "019adfbb-40a1-71ee-bcb5-8d551b8cfdae",
                "original_link": "https://www.google.com/search?q=calisero&rlz=1C5GCEM_enRO1174RO1174&oq=calisero&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPDIGCAMQRRg7MgcIBBAAGO8FMgoIBRAAGIAEGKIEMgoIBhAAGIAEGKIEMgcIBxAAGO8F0gEJMTcxNWowajE1qAIIsAIB&sourceid=chrome&ie=UTF-8",
                "shortened_link": "https://calisero.ro/s/ghJKPV",
                "click_count": 0,
                "last_click": null,
                "created_at": "2025-12-02T15:43:02.000000Z"
            },
            {
                "id": "019adfbb-40a4-70af-ba6c-55b7a07826e6",
                "original_link": "https://calisero.ro/stop/019999b5-5687-71cd-ae81-baf6eea9d86f/+40742***350?reason=Unsubscribe+from+SMS",
                "shortened_link": "https://calisero.ro/s/qpmHfD",
                "click_count": 0,
                "last_click": null,
                "created_at": "2025-12-02T15:43:02.000000Z"
            }
        ]
    }
}
```

Headers of the `201` response:

| Header | Type | Description |
| --- | --- | --- |
| `X-Daily-Limit` | integer | The account's daily sending limit: how many messages it can send in a day, midnight to midnight, Romania time. Sent only while the account has a limit. Example: `1000`. |
| `X-Daily-Remaining` | integer | How many messages the account can still send today, after this one. Sent only while the account has a limit. Example: `873`. |
| `X-RateLimit-Limit` | integer | The request rate limit: how many requests the API user may make a minute. Sent with every answer. Example: `240`. |
| `X-RateLimit-Remaining` | integer | How many requests the API user may still make this minute. Sent with every answer; 0 on a refusal of the request rate limit. Example: `239`. |

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `422` The payload failed validation. The response lists the offending fields. Body: `{"message":"The recipient field is required.","errors":{"recipient":["The recipient field is required."]}}`

- `429` Too many requests, for one of two reasons. The account reached its daily sending limit: the body carries the code daily_limit_exceeded, X-Daily-Remaining is 0 and Retry-After counts the seconds until midnight, Romania time. Or the user exceeded the request rate limit of 240 requests a minute: the body is {"message": "Too Many Attempts.", "trace_id": "…"} without a code, X-RateLimit-Remaining is 0, and Retry-After and X-RateLimit-Reset say when to retry. Nothing is sent and nothing is billed. Body: `{"message":"This account can send at most 1,000 messages a day. The limit resets at midnight, Romania time (2026-10-01T00:00:00+03:00). Contact us to raise it.","code":"daily_limit_exceeded","daily_limit":1000,"daily_remaining":0,"resets_at":"2026-10-01T00:00:00+03:00","trace_id":"9b80eef1-49d4-4502-85a8-febb68cc11a7"}`

Headers of the `429` response:

| Header | Type | Description |
| --- | --- | --- |
| `Retry-After` | integer | Seconds to wait before sending again: until midnight, Romania time, for the daily sending limit; until the request rate limit frees up for that one. Example: `21600`. |
| `X-Daily-Limit` | integer | The account's daily sending limit: how many messages it can send in a day, midnight to midnight, Romania time. Sent only while the account has a limit. Example: `1000`. |
| `X-Daily-Remaining` | integer | 0 when the daily sending limit refused the message; absent on a refusal of the request rate limit. Example: `0`. |
| `X-RateLimit-Limit` | integer | The request rate limit: how many requests the API user may make a minute. Sent with every answer. Example: `240`. |
| `X-RateLimit-Remaining` | integer | How many requests the API user may still make this minute. Sent with every answer; 0 on a refusal of the request rate limit. Example: `239`. |
| `X-RateLimit-Reset` | integer | Only on a refusal of the request rate limit: when requests are accepted again, as a Unix timestamp. Example: `1790000000`. |

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### GET /messages

Retrieve a list of messages

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number for pagination |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/messages' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` List of messages

```json
{
    "data": [
        {
            "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
            "recipient": "40752******",
            "body": "Mesaje de test anonimizat ***",
            "parts": 1,
            "created_at": "2024-09-20T12:48:55.000000Z",
            "scheduled_at": "2024-09-20T12:48:55.000000Z",
            "sent_at": "2024-09-20T12:49:07.000000Z",
            "delivered_at": null,
            "callback_url": "https://yoursite.com/your-callback-url",
            "status": "sent",
            "sender": "CALISERO",
            "shortened_urls": [
                {
                    "id": "019adfbb-40a1-71ee-bcb5-8d551b8cfdae",
                    "original_link": "https://www.google.com/search?q=calisero&rlz=1C5GCEM_enRO1174RO1174&oq=calisero&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPDIGCAMQRRg7MgcIBBAAGO8FMgoIBRAAGIAEGKIEMgoIBhAAGIAEGKIEMgcIBxAAGO8F0gEJMTcxNWowajE1qAIIsAIB&sourceid=chrome&ie=UTF-8",
                    "shortened_link": "https://calisero.ro/s/ghJKPV",
                    "click_count": 0,
                    "last_click": null,
                    "created_at": "2025-12-02T15:43:02.000000Z"
                }
            ]
        }
    ],
    "links": {
        "first": "https://rest.calisero.ro/api/v1/messages?page=1",
        "last": null,
        "prev": null,
        "next": "https://rest.calisero.ro/api/v1/messages?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "path": "https://rest.calisero.ro/api/v1/messages",
        "per_page": 50,
        "to": 50
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### GET /messages/{messageId}

Retrieve a single message

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `messageId` | path | string (uuid) | yes | The UUID of the message |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/messages/{messageId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` Message details

```json
{
    "data": {
        "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
        "recipient": "40752******",
        "body": "Mesaje de test anonimizat ***",
        "parts": 1,
        "created_at": "2024-09-20T12:48:55.000000Z",
        "scheduled_at": "2024-09-20T12:48:55.000000Z",
        "sent_at": "2024-09-20T12:49:07.000000Z",
        "delivered_at": null,
        "callback_url": "https://yoursite.com/your-callback-url",
        "status": "sent",
        "sender": "CALISERO",
        "shortened_urls": [
            {
                "id": "019adfbb-40a1-71ee-bcb5-8d551b8cfdae",
                "original_link": "https://www.google.com/search?q=calisero&rlz=1C5GCEM_enRO1174RO1174&oq=calisero&gs_lcrp=EgZjaHJvbWUqBggAEEUYOzIGCAAQRRg7MgYIARBFGDwyBggCEEUYPDIGCAMQRRg7MgcIBBAAGO8FMgoIBRAAGIAEGKIEMgoIBhAAGIAEGKIEMgcIBxAAGO8F0gEJMTcxNWowajE1qAIIsAIB&sourceid=chrome&ie=UTF-8",
                "shortened_link": "https://calisero.ro/s/ghJKPV",
                "click_count": 0,
                "last_click": null,
                "created_at": "2025-12-02T15:43:02.000000Z"
            }
        ]
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### DELETE /messages/{messageId}

Delete a message

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `messageId` | path | string (uuid) | yes | The UUID of the message |

Example request:

```bash
curl -X DELETE 'https://rest.calisero.ro/api/v1/messages/{messageId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` Message deleted successfully

```json
[]
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `403` The API key is valid but not allowed to perform this action. Body: `{"message":"You are not authorized to delete this resource or the resource has been queued, sent or delivered"}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

## Opt-outs

### GET /opt-outs

Retrieve a list of opt-outs

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number for pagination |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/opt-outs' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` List of opt-outs

```json
{
    "data": [
        {
            "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
            "phone": "40752******",
            "reason": "User requested opt-out",
            "created_at": "2024-09-20T12:48:55.000000Z",
            "updated_at": "2024-09-20T12:48:55.000000Z"
        }
    ],
    "links": {
        "first": "https://rest.calisero.ro/api/v1/opt-outs?page=1",
        "last": null,
        "prev": null,
        "next": "https://rest.calisero.ro/api/v1/opt-outs?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "path": "https://rest.calisero.ro/api/v1/opt-outs",
        "per_page": 50,
        "to": 50
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### POST /opt-outs

Create a new opt-out

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone` | string | yes | Phone number to opt-out (in E.164 format) |
| `reason` | string | no | Reason for opt-out |

Example request:

```bash
curl -X POST 'https://rest.calisero.ro/api/v1/opt-outs' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "40752******",
    "reason": "User requested opt-out"
}'
```

Responses:

- `201` Opt-out created successfully

```json
{
    "data": {
        "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
        "phone": "40752******",
        "reason": "User requested opt-out",
        "created_at": "2024-09-20T12:48:55.000000Z",
        "updated_at": "2024-09-20T12:48:55.000000Z"
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `422` The payload failed validation. The response lists the offending fields. Body: `{"message":"The recipient field is required.","errors":{"recipient":["The recipient field is required."]}}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### GET /opt-outs/{optOutId}

Retrieve a single opt-out

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `optOutId` | path | string (uuid) | yes | The UUID of the opt-out |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/opt-outs/{optOutId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` Opt-out details

```json
{
    "data": {
        "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
        "phone": "40752******",
        "reason": "User requested opt-out",
        "created_at": "2024-09-20T12:48:55.000000Z",
        "updated_at": "2024-09-20T12:48:55.000000Z"
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### PUT /opt-outs/{optOutId}

Update an opt-out

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `optOutId` | path | string (uuid) | yes | The UUID of the opt-out |

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone` | string | yes | Phone number to opt-out (in E.164 format) |
| `reason` | string | no | Reason for opt-out |

Example request:

```bash
curl -X PUT 'https://rest.calisero.ro/api/v1/opt-outs/{optOutId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "40752******",
    "reason": "Updated reason"
}'
```

Responses:

- `200` Opt-out updated successfully

```json
{
    "data": {
        "id": "9d0e0bd3-c805-4dd0-bafb-07c5ded0a8e1",
        "phone": "40752******",
        "reason": "Customer asked to stop the reminders",
        "created_at": "2024-09-20T12:48:55.000000Z",
        "updated_at": "2024-10-02T08:15:31.000000Z"
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `422` The payload failed validation. The response lists the offending fields. Body: `{"message":"The recipient field is required.","errors":{"recipient":["The recipient field is required."]}}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### DELETE /opt-outs/{optOutId}

Delete an opt-out

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `optOutId` | path | string (uuid) | yes | The UUID of the opt-out |

Example request:

```bash
curl -X DELETE 'https://rest.calisero.ro/api/v1/opt-outs/{optOutId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` Opt-out deleted successfully

```json
[]
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

## Verifications (OTP & 2FA)

### GET /verifications

Retrieve a list of verifications

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer | no | Page number for pagination |
| `status` | query | string | no | Filter by verification status |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/verifications' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` List of verifications

```json
{
    "data": [
        {
            "id": "019a62f1-66b7-7387-a64f-2742c12a2860",
            "phone": "+40742**350",
            "brand": "Calisero",
            "status": "verified",
            "template": null,
            "created_at": "2025-11-08T10:09:38.000000Z",
            "expires_at": "2025-11-08T10:12:38.000000Z",
            "verified_at": "2025-11-08T10:10:18.000000Z",
            "attempts": 1,
            "expired": false
        }
    ],
    "links": {
        "first": "https://rest.calisero.ro/api/v1/verifications?page=1",
        "last": null,
        "prev": null,
        "next": "https://rest.calisero.ro/api/v1/verifications?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "path": "https://rest.calisero.ro/api/v1/verifications",
        "per_page": 50,
        "to": 50
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `403` The API key is valid but not allowed to perform this action. Body: `{"message":"You don't have permission to access this resource."}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### POST /verifications

Create a new verification request

Generates a code, sends it by SMS to `phone` and keeps it for `PUT /verifications/validate`.

**Daily sending limit.** The code's SMS counts towards the account's daily limit like any message of `POST /messages` (a new account starts with 200 messages a day); the day ends at midnight, Romania time (Europe/Bucharest). While the account has a limit, the answer to a created verification carries `X-Daily-Limit` and `X-Daily-Remaining`. Once the limit is reached the answer is `429` with `"code": "daily_limit_exceeded"`, `Retry-After` and `X-Daily-Remaining: 0` until midnight, Romania time: no code is sent and nothing is billed. See [Daily sending limit](https://docs.calisero.ro/#daily-limit).

**Request rate limit.** Separately, each API user may make 240 requests a minute; above the rate the answer is `429` with `{"message": "Too Many Attempts.", "trace_id": "…"}`, no `code`, plus `Retry-After` and `X-RateLimit-Reset`.

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone` | string | yes | Phone number to verify (E.164 format) |
| `brand` | string | no | Brand used in default message when template is not provided. It may not contain links (web addresses, domains, emails or IP addresses). |
| `template` | string | no | Custom SMS template that MUST include {code}. It may not contain links (web addresses, domains, emails or IP addresses): the code is sent from a shared sender. |
| `expires_in` | integer | no | OTP validity in minutes (1-10). Default 5. |

Example request:

```bash
curl -X POST 'https://rest.calisero.ro/api/v1/verifications' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "+40742**350",
    "brand": "Calisero"
}'
```

Responses:

- `201` Verification created successfully

```json
{
    "data": {
        "id": "019a62f1-66b7-7387-a64f-2742c12a2860",
        "phone": "+40742**350",
        "brand": "Calisero",
        "status": "unverified",
        "template": null,
        "created_at": "2025-11-08T10:09:38.000000Z",
        "expires_at": "2025-11-08T10:12:38.000000Z",
        "verified_at": null,
        "attempts": 0,
        "expired": false
    }
}
```

Headers of the `201` response:

| Header | Type | Description |
| --- | --- | --- |
| `X-Daily-Limit` | integer | The account's daily sending limit: how many messages it can send in a day, midnight to midnight, Romania time. Sent only while the account has a limit. Example: `1000`. |
| `X-Daily-Remaining` | integer | How many messages the account can still send today, after this one. Sent only while the account has a limit. Example: `873`. |
| `X-RateLimit-Limit` | integer | The request rate limit: how many requests the API user may make a minute. Sent with every answer. Example: `240`. |
| `X-RateLimit-Remaining` | integer | How many requests the API user may still make this minute. Sent with every answer; 0 on a refusal of the request rate limit. Example: `239`. |

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `403` The API key is valid but not allowed to perform this action. Body: `{"message":"You don't have permission to access this resource."}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `422` The payload failed validation. The response lists the offending fields. Body: `{"message":"The recipient field is required.","errors":{"recipient":["The recipient field is required."]}}`

- `429` Too many requests, for one of two reasons. The account reached its daily sending limit: the body carries the code daily_limit_exceeded, X-Daily-Remaining is 0 and Retry-After counts the seconds until midnight, Romania time. Or the user exceeded the request rate limit of 240 requests a minute: the body is {"message": "Too Many Attempts.", "trace_id": "…"} without a code, X-RateLimit-Remaining is 0, and Retry-After and X-RateLimit-Reset say when to retry. No code is sent and nothing is billed. Body: `{"message":"This account can send at most 1,000 messages a day. The limit resets at midnight, Romania time (2026-10-01T00:00:00+03:00). Contact us to raise it.","code":"daily_limit_exceeded","daily_limit":1000,"daily_remaining":0,"resets_at":"2026-10-01T00:00:00+03:00","trace_id":"9b80eef1-49d4-4502-85a8-febb68cc11a7"}`

Headers of the `429` response:

| Header | Type | Description |
| --- | --- | --- |
| `Retry-After` | integer | Seconds to wait before sending again: until midnight, Romania time, for the daily sending limit; until the request rate limit frees up for that one. Example: `21600`. |
| `X-Daily-Limit` | integer | The account's daily sending limit: how many messages it can send in a day, midnight to midnight, Romania time. Sent only while the account has a limit. Example: `1000`. |
| `X-Daily-Remaining` | integer | 0 when the daily sending limit refused the message; absent on a refusal of the request rate limit. Example: `0`. |
| `X-RateLimit-Limit` | integer | The request rate limit: how many requests the API user may make a minute. Sent with every answer. Example: `240`. |
| `X-RateLimit-Remaining` | integer | How many requests the API user may still make this minute. Sent with every answer; 0 on a refusal of the request rate limit. Example: `239`. |
| `X-RateLimit-Reset` | integer | Only on a refusal of the request rate limit: when requests are accepted again, as a Unix timestamp. Example: `1790000000`. |

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### GET /verifications/{verificationId}

Retrieve a single verification

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `verificationId` | path | string (uuid) | yes | The UUID of the verification |

Example request:

```bash
curl -X GET 'https://rest.calisero.ro/api/v1/verifications/{verificationId}' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json'
```

Responses:

- `200` Verification details

```json
{
    "data": {
        "id": "019a62f1-66b7-7387-a64f-2742c12a2860",
        "phone": "+40742**350",
        "brand": "Calisero",
        "status": "verified",
        "template": null,
        "created_at": "2025-11-08T10:09:38.000000Z",
        "expires_at": "2025-11-08T10:12:38.000000Z",
        "verified_at": "2025-11-08T10:10:18.000000Z",
        "attempts": 1,
        "expired": false
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `403` The API key is valid but not allowed to perform this action. Body: `{"message":"You don't have permission to access this resource."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

### PUT /verifications/validate

Verify a code (OTP) for a phone number

Request body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone` | string | yes | Phone number to verify (E.164 format) |
| `code` | string | yes | 6 character OTP code (case-insensitive) |

Example request:

```bash
curl -X PUT 'https://rest.calisero.ro/api/v1/verifications/validate' \
  -H 'Authorization: Bearer {token}' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "+40742**350",
    "code": "SBMH0f"
}'
```

Responses:

- `200` Phone number verified successfully (idempotent if already verified)

```json
{
    "data": {
        "id": "019a62f1-66b7-7387-a64f-2742c12a2860",
        "phone": "+40742**350",
        "brand": "Calisero",
        "status": "verified",
        "template": null,
        "created_at": "2025-11-08T10:09:38.000000Z",
        "expires_at": "2025-11-08T10:12:38.000000Z",
        "verified_at": "2025-11-08T10:10:18.000000Z",
        "attempts": 1,
        "expired": false
    }
}
```

- `401` The API key is missing or invalid. Body: `{"message":"Unauthenticated."}`

- `403` The API key is valid but not allowed to perform this action. Body: `{"message":"You don't have permission to access this resource."}`

- `404` No resource matches the given identifier. Body: `{"message":"Resource not found!"}`

- `405` The HTTP method is not supported for this endpoint. Body: `{"message":"This method is not supported for the requested route."}`

- `422` Validation errors (e.g., code incorrect, expired, attempts exceeded) Body: `{"message":"string","errors":[],"trace_id":"9b80eef1-49d4-4502-85a8-febb68cc11a7"}`

- `500` Something went wrong on our side. Retry, and contact support if it persists. Body: `{"message":"Internal server error!"}`

## Webhooks

### Delivery status webhook (Calisero -> Client)

Calisero sends `POST` with a JSON body to your callback URL. The request carries no API key.

Calisero sends delivery status updates to the callback URL you provide when creating a message (`callback_url`). This callback documents the JSON payload you should expect to receive. Your endpoint must return any 2xx status code to acknowledge receipt. Only a failed connection or no answer within 2 seconds is retried, at most 5 attempts in total (after 1 s, 10 s, 1 min and 6 min); a non-2xx response is not retried.

Payload:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `price` | number (float) | yes | Price charged for the message part(s). |
| `sender` | string | yes | Sender ID or phone number. |
| `status` | string, one of `sent`, `delivered`, `undelivered` | yes | Delivery status. |
| `messageId` | string (uuid) | yes | Unique identifier of the message. |
| `recipient` | string | yes | Recipient MSISDN in E.164 format. |
| `sentAt` | string (date-time) | no | Timestamp when the message was sent to the network (present for statuses sent or delivered). |
| `deliveredAt` | string (date-time) | no | Timestamp when the handset confirmed delivery (present only for delivered). |
| `scheduleAt` | string (date-time) | no | Original scheduled sending time if the message was scheduled. |
| `remainingBalance` | number (float) | no | Remaining account balance after billing (if available). |
| `dailyLimit` | integer | no | The account's daily sending limit when the callback was sent; null when no daily limit applies. The same as daily_limit in GET /accounts/{accountId}. |
| `dailyRemaining` | integer | no | How many messages the account could still send today when the callback was sent; null when no daily limit applies. The same as daily_remaining in GET /accounts/{accountId}. |
| `sentToday` | integer | no | The real messages the account created today, Romania time, when the callback was sent: OTP codes included, test messages left out, one per message whatever its parts. The same as sent_today in GET /accounts/{accountId}. |

Example payload:

```json
{
    "price": 0.0378,
    "sender": "CALISERO",
    "sentAt": "2025-09-19T12:01:15.000000Z",
    "status": "delivered",
    "messageId": "019961db-14a7-7348-963f-5a7a789a969f",
    "recipient": "+40742**350",
    "scheduleAt": "2025-09-19T12:02:51.000000Z",
    "deliveredAt": "2025-09-19T12:02:53.000000Z",
    "remainingBalance": 999.392,
    "dailyLimit": 1000,
    "dailyRemaining": 588,
    "sentToday": 412
}
```
