# Self SMS Portal API

Base URL:

```text
https://your-sms-host.example/api/v1
```

Use HTTPS in production. Requests with bodies use `Content-Type:
application/json`. Successful responses include `"success": true`. Errors use
an HTTP status plus a stable `code` where available:

```json
{
  "success": false,
  "error": "A human-readable explanation.",
  "code": "stable_error_code"
}
```

Rate-limited requests return `429` and `Retry-After`.

## Authentication

| Context | Credential |
|---|---|
| Account API client | `Authorization: Bearer <JWT>` |
| Web dashboard | Secure `auth_token` cookie |
| Initial phone pairing | `Pairing-Grant: <one-time-64-hex-grant>` |
| Paired phone | `Device-UUID: <uuid>` and `Device-Token: <token>` |
| Stripe webhook | Verified `Stripe-Signature` |

Cookie-authenticated mutations require the exact canonical application origin
in the `Origin` header. Production CORS is an explicit origin allowlist.
Credentials, pairing grants, device/lease tokens, SMS content, phone numbers,
and Checkout Session URLs must not be logged.

## Pair a phone

1. An authenticated browser calls `POST /pairing/grants` with `{}`.
2. The Flutter app presents the returned value in `Pairing-Grant`.
3. `POST /pairing/validate` checks the grant without consuming it.
4. `POST /device/register` consumes it and returns `device_token` once.
5. Later phone requests use `Device-UUID` and `Device-Token`.

Grants expire in 2-15 minutes (10 minutes by default) and one successful
registration consumes them. Removing a phone revokes its token.

Example registration body:

```json
{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "android_version": "14",
  "manufacturer": "Example",
  "model": "Gateway Phone",
  "app_version": "1.3.0",
  "sims": [
    {
      "subscriptionId": "1",
      "slotIndex": 0,
      "carrierName": "Carrier",
      "displayName": "Primary SIM"
    }
  ]
}
```

Free accounts can register one phone. Paid accounts use the configured device
and SIM limits.

## SMS policy

Every send declares `purpose`: `transactional`, `service`, or `marketing`.
Marketing requires `"consent_confirmed": true`; every bulk request requires
explicit consent/suppression confirmation. These flags record operator intent
but do not replace applicable consent, sender-identification, opt-out,
quiet-hours, or recordkeeping obligations.

Phone input permits one leading `+`, digits, spaces, parentheses, dots, and
hyphens, and must normalize to 7-15 digits. Letters, extensions, and repeated
plus signs are rejected.

Messages must fit one segment after any plan footer:

- GSM-7: 160 units; extension-table characters use two units.
- UCS-2: 70 UTF-16 code units.

## Queue one message

`POST /sms/send`

Headers:

```text
Authorization: Bearer <JWT>
Idempotency-Key: <unique-8-to-128-character-key>
Content-Type: application/json
```

Body:

```json
{
  "phone": "+447700900123",
  "message": "Your appointment is confirmed.",
  "purpose": "transactional",
  "consent_confirmed": false,
  "device_uuid": null,
  "sim_id": null,
  "fallback_to_any": false
}
```

The response means queued, not carrier-delivered:

```json
{
  "success": true,
  "job_id": "0123456789abcdef",
  "status": "pending",
  "poll_url": "/api/v1/info?include=jobs&job_id=0123456789abcdef"
}
```

Reusing an idempotency key with the same body returns the original response.
Reusing it with different input returns `409`.

## Queue a batch

`POST /sms/send-bulk` also requires an `Idempotency-Key`.

```json
{
  "batch_name": "Service reminders",
  "message": "Your service visit is tomorrow.",
  "recipients_text": "+447700900123\n+447700900124",
  "purpose": "service",
  "consent_confirmed": true,
  "delay_mode": "static",
  "delay_config": {
    "delay_seconds": 30
  },
  "device_uuid": null,
  "sim_id": null,
  "fallback_to_any": false
}
```

`phones` may be an array. `recipients_text` may be separated by new lines,
commas, or semicolons. Limits are 100 recipients for free accounts and 1,000
for paid accounts. Delay modes are `none`, `static`, `random`, and `campaign`;
scheduling controls queue eligibility, not guaranteed carrier delivery time.

## Phone delivery lifecycle

The paired phone calls `GET /sms/pending?limit=1`. Claiming changes a job to
`processing` and returns `lease_token` plus `lease_expires_at`. Explicit
device/SIM routing is honored.

`POST /sms/update`:

```json
{
  "job_id": "0123456789abcdef",
  "status": "sent",
  "lease_token": "<lease-token>",
  "error_message": ""
}
```

Allowed transitions are `processing -> sent|delivered|failed` and
`sent -> delivered|failed`. The device UUID and lease must match the current
claim. Repeating an accepted final report is idempotent.

`POST /sms/update-batch` accepts an `updates` array and returns one result per
item plus `all_succeeded`; one rejected entry does not hide successful entries.

## History

`GET /info`

| Query | Values |
|---|---|
| `include` | `all`, `jobs`, `devices`, or `user_info` |
| `job_id` | One 16-character hexadecimal job ID |
| `job_status` | `pending`, `processing`, `sent`, `delivered`, or `failed` |
| `page` | Positive integer |
| `limit` | 1-100; default 25 |

Device-authenticated reads are restricted to that phone and its eligible or
claimed work.

## Suppression

Account-authenticated endpoints:

- `GET /suppression?page=1&limit=100`
- `POST /suppression/add`
- `POST /suppression/remove`

Add/remove body:

```json
{
  "phones": ["+447700900123"],
  "reason": "Recipient opted out"
}
```

At most 100 entries can change per call. Equivalent leading-plus formatting is
canonicalized. Suppressed recipients are rejected before jobs are created.

## Accounts and billing

- `POST /register`: username (3-32), email (max 254), password (12-128),
  and plan (`free` or `paid`).
- `POST /login`: username and password.
- `POST /user/settings`: username and/or new password.
- `POST /user/delete`: current password and `"confirm": "CONFIRM"`.
- `POST /billing/add-payment`: creates or reuses an owned Stripe Checkout
  Session.
- `POST /verify_payment`: verifies only the signed-in user's owned session.
- `POST /billing/portal`: creates an owned Billing Portal session.
- `POST /billing/cancel`: confirms cancellation/expiry before removing access.
- `POST /stripe/webhook`: verifies signatures, deduplicates event IDs, and
  applies per-subscription event ordering.

The legacy `/checkout` card-capture endpoint is removed and returns `410`.
Raw card data never enters this application.

## Administration

Administrator JWTs use a separate audience:

- `POST /admin/login`
- `GET /admin/users`
- `POST /admin/user/update`

Plan and suspension changes are audited. Granting paid features without Stripe
requires `"manual_entitlement_override": true` and is recorded as a manual
entitlement. A Stripe-managed downgrade confirms cancellation first.

## Endpoint catalogue

| Method | Path | Authentication |
|---|---|---|
| POST | `/register` | Public |
| POST | `/login` | Public |
| POST | `/verify_payment` | Account |
| POST | `/billing/add-payment` | Account |
| POST | `/billing/portal` | Account |
| POST | `/billing/cancel` | Account |
| POST | `/user/settings` | Account |
| POST | `/user/delete` | Account |
| POST | `/stripe/webhook` | Stripe signature |
| POST | `/pairing/grants` | Account |
| POST | `/pairing/validate` | Pairing grant |
| POST | `/device/register` | Pairing grant or device |
| POST | `/device/heartbeat` | Device |
| POST | `/device/delete` | Account |
| POST | `/sms/send` | Account or device |
| POST | `/sms/send-bulk` | Account or device |
| GET | `/sms/pending?limit=1` | Device |
| POST | `/sms/update` | Device |
| POST | `/sms/update-batch` | Device |
| GET | `/info` | Account or device |
| GET | `/suppression` | Account |
| POST | `/suppression/add` | Account |
| POST | `/suppression/remove` | Account |
| POST | `/admin/login` | Public, rate-limited |
| GET | `/admin/users` | Administrator |
| POST | `/admin/user/update` | Administrator |

## Common status codes

| Status | Meaning |
|---|---|
| `200` | Request accepted |
| `400` | Invalid input |
| `401` | Missing or invalid credential |
| `403` | Origin, account, policy, plan, or route is not allowed |
| `404` | Resource or endpoint not found |
| `409` | Idempotency, claim, or state conflict |
| `410` | Revoked/removed resource or retired endpoint |
| `413` | Request too large |
| `422` | Valid request could not be applied |
| `429` | Rate limited; honor `Retry-After` |
| `500` | Server configuration or internal failure |
| `502` | Upstream billing/provider failure |
| `503` | Service temporarily unavailable |

## Operational guarantees and limits

- Default dispatch limits are 10 messages/minute for free accounts and 50 for
  paid accounts; operators may configure bounded values.
- The JSON store is a single-writer deployment. Do not share one data volume
  between multiple PHP replicas.
- `queued` or `sent` is not proof of handset delivery. Show `delivered` only
  when the phone/carrier path reports it.
- Use a new `Idempotency-Key` for every externally initiated send and persist
  the returned job ID.