SMS Gateway API - integration guide

Base URL: https://smsgw.ownbit.org (development: http://localhost:8400).

Everything below is also published by the service itself, with no login:

URL What
/swagger-ui.html interactive API reference - every endpoint and model, with a "Try it out" console
/openapi.yaml, /openapi.json the machine-readable contract (OpenAPI 3.0) for Postman / Insomnia / code generators
/docs this guide

Import /openapi.yaml into Postman or Insomnia for a ready-made collection, or generate a typed client with ./gradlew generateSdks (TypeScript, Python, PHP, Java -> build/sdks/).

1. Get access

An administrator creates a tenant for your project and a user inside it, then you create an API key for each integration:

# log in as your user (people use JWTs), then mint a key
TOKEN=$(curl -s https://smsgw.ownbit.org/api/v1/auth/login \
  -H 'Content-Type: application/json' -d '{"email":"dev@acme.ro","password":"..."}' | jq -r .accessToken)

curl -s https://smsgw.ownbit.org/api/v1/api-keys -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"billing-service prod","scopes":["sms:send","sms:read"]}'
# -> {"key":"sk_live_...","apiKey":{...}}   (the key is shown ONCE - store it in your secret manager)

Use it as Authorization: Bearer sk_live_... or X-API-Key: sk_live_....

Scope Allows
sms:send queue / cancel messages, batch, verify, suppression list
sms:read read messages, inbox, usage, plan / billing, invoices, estimates
contacts:manage / templates:manage contacts, groups / templates
webhooks:manage webhook endpoints

A key can never manage keys, change passwords or invoice details, or act as an admin. It sends against the plan of the user that created it. Revoke a leaked key with DELETE /api/v1/api-keys/{id} - it stops working on the next request.

2. Send a message

Sending is asynchronous: the API accepts the message into a durable queue and answers 202 immediately, even if the modem is busy or offline. A dispatcher sends at the modem's pace (about 20 SMS/minute), ordered by priority and shared fairly between tenants.

curl -s https://smsgw.ownbit.org/api/v1/messages \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: order-8841-confirmation" \
  -d '{"to":"+40760123456","text":"Comanda 8841 a fost expediata.","priority":"NORMAL",
       "clientReference":"order-8841","transliterate":true}'
{ "id": "6f1c...", "status": "QUEUED", "to": "0040760123456", "segments": 1, "encoding": "GSM7",
  "estimatedSendAt": "2026-10-16T09:00:20", "clientReference": "order-8841" }

Statuses: QUEUED -> SENDING -> SENT | FAILED, plus EXPIRED, CANCELLED. FAILED.errorCode: GATEWAY_REJECTED (modem refused it), GATEWAY_UNAVAILABLE (modem unreachable after retries), UNKNOWN_DELIVERY (see below), INVALID_REQUEST.

UNKNOWN_DELIVERY is deliberate. If the modem stops answering after receiving a send, we cannot know whether it went out. We never resend in that case (that could text your customer twice) - the message is reported as FAILED/UNKNOWN_DELIVERY and you decide whether to retry. Failures where the request provably never reached the modem (connection refused, circuit open) are retried automatically with backoff.

SENT means the modem accepted and sent it, not that the handset received it. Carrier delivery receipts are not available through the TRB140 interface used here.

Segments and cost

A GSM-7 SMS is 160 characters (153 per part when concatenated). One non-GSM character - any Romanian diacritic - switches the whole message to UCS-2: 70 characters (67 per part). ț can double the SMS used. POST /api/v1/messages/estimate returns encoding, segments and whether your plan covers it (planActive, unitsAvailable, sufficient) before you send; transliterate: true avoids the surcharge.

Batches

curl -s https://smsgw.ownbit.org/api/v1/messages/batch -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: campaign-2026-10-promo' \
  -d '{"priority":"BULK","messages":[
        {"to":"+40760111111","text":"Salut Ana!"},
        {"to":"+40760222222","templateId":12,"templateParams":{"name":"Ion","code":"X7"}}]}'

Up to 1000 items. Always 202; the body lists ACCEPTED/REJECTED per item with an errorCode (INVALID_REQUEST, SUPPRESSED, TEMPLATE_NOT_FOUND, QUEUE_CAPACITY, ...). If your plan cannot cover the whole batch you get 402 and nothing is queued.

3. Verify (one-time codes)

curl -s .../api/v1/verify -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"to":"+40760123456","codeLength":6,"ttlSeconds":300}'          # -> {"id":"...","messageId":"..."}
curl -s .../api/v1/verify/check -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"id":"...","code":"482913"}'                                   # -> {"valid":true,"status":"VERIFIED"}

The code is sent with OTP priority and never returned by the API or stored in clear. 5 attempts, then the verification is locked. Another code to the same number within 30 s is 429.

4. Webhooks

POST /api/v1/webhooks with an https URL (private/internal addresses are refused). The secret in the response is shown once. Events: message.queued, message.sent, message.failed, message.expired, message.cancelled, inbound.received, plan.credits_low.

Every delivery is signed. Verify it before trusting the body:

X-Signature: t=1760000000,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
X-Event-Type: message.sent        X-Delivery-Id: <uuid>
import hmac, hashlib, time
def valid(secret, header, raw_body):
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300: return False          # replay protection
    expected = hmac.new(secret.encode(), f'{parts["t"]}.{raw_body}'.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Delivery is at-least-once with retries (30 s, 60 s, 2 min ... up to 8 attempts): dedupe on the event id, and answer 2xx quickly. See attempts with GET /api/v1/webhooks/{id}/deliveries, test with POST /api/v1/webhooks/{id}/test, replay with POST /api/v1/webhook-deliveries/{id}/redeliver.

5. Receiving SMS and opt-outs

GET /api/v1/inbound-messages (or the inbound.received webhook) returns messages received on the modem, attributed to the tenant that last texted that number. A reply of STOP (also UNSUBSCRIBE, DEZABONARE, OPRESTE...) adds the sender to your suppression list: further messages to them are refused with 422 SUPPRESSED (OTP-priority messages are exempt). START lifts it. Manage the list with GET/POST /api/v1/suppressions and DELETE /api/v1/suppressions/{id}.

Quiet hours (set per tenant by an admin) hold BULK traffic until the window ends, in the tenant's time zone.

6. Plans, billing and invoices

You send against a plan that an administrator assigns to your user (there is no self-service purchase). Plans are paid in advance, do not expire, and are defined in the database, so they can be changed at any time. Without an active plan nobody can send - not even an administrator.

Administrators (role ADMIN only) manage everything: GET/POST /api/v1/admin/plans, PUT|DELETE /api/v1/admin/plans/{id} (edit or deactivate - users keep the terms they bought), POST /api/v1/admin/users/{userId}/plan {"planId":2} ("quantity" for PER_SMS), GET /api/v1/admin/users/{userId}/billing, GET /api/v1/admin/invoices?userId=&status=, POST /api/v1/admin/invoices/{id}/payment {"reference":"OP 1234"}, POST /api/v1/admin/invoices/{id}/cancel and POST /api/v1/admin/users/{userId}/usage-adjustments {"units":-5,"reason":"..."}. Assigning a new plan while another is still unpaid cancels the old invoice.

7. Limits and errors

Rate limit = the requests/minute of your plan, per user. Every /messages, /messages/batch and /verify response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (seconds); 429 adds Retry-After. A batch counts as one unit per 10 messages.

Errors use RFC 7807 application/problem+json with a requestId (also in the X-Request-ID header - quote it to support) and, where useful, a stable errorCode.

Status Meaning
400 invalid number / text / parameters
401 / 403 bad or revoked key / missing scope or tenant
402 no active plan (PLAN_REQUIRED) or plan used up (PLAN_LIMIT_REACHED)
409 Idempotency-Key reused with a different request
422 recipient is suppressed (opted out)
429 rate limit - wait Retry-After
503 queue full, cannot meet validUntil, modem circuit open, or no modem configured (NO_MODEM_CONFIGURED, legacy send only) - retry after Retry-After

Message text and phone numbers are redacted from the system after 90 days (configurable per tenant); counts, segments and costs are kept for reporting.