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" }
Idempotency-Key- always send one. Repeating the same key + body returns the original message (200) instead of sending a second SMS; the same key with a different body is409. Keys are remembered for 24 h. Use this to retry safely after a timeout.priority-OTPgoes beforeNORMALbeforeBULK. UseOTPfor codes,BULKfor campaigns.validUntil- the message expires unsent instead of arriving late (essential for OTPs). If the queue is so long it cannot be delivered in time, you get503+Retry-Afterimmediately.sendAt- hold until a time.transliterate-ă ș ț -> a s tto keep the SMS GSM-7 (see below).GET /api/v1/messages/{id}- status.DELETEcancels while stillQUEUED.GET /api/v1/messages?status=&to=&createdFrom=&cursor=&limit=lists newest first (cursor pagination).
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_DELIVERYis 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 asFAILED/UNKNOWN_DELIVERYand you decide whether to retry. Failures where the request provably never reached the modem (connection refused, circuit open) are retried automatically with backoff.
SENTmeans 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.
GET /api/v1/plans(public) lists what is on offer, excluding VAT: aPACKAGEis a fixed number of SMS for a fixed price (e.g. Starter: 500 SMS for 99 RON);PER_SMS(Pay as you go) chargesunitPriceper SMS for the quantity the administrator picks (10 SMS, not 100). AnUNLIMITEDplan exists too, but only for administrators and is not listed.- Every assignment issues an invoice (
UNPAID) with its lines, VAT (21%) and a snapshot of your billing details. The plan becomes active when the administrator marks the invoice paid; its SMS are then added to those you still have (nothing is lost when you change plan). Until then,pendingPlanshows what is waiting andamountDuewhat you owe. Nothing is rendered or e-mailed - the data is stored and readable via the API. GET /api/v1/billing- youractivePlan,pendingPlan,unitsAvailable(SMS you can still queue),unitsReserved(held for queued messages),unitsUsedTotal,unlimited,amountDueandcurrency.- One SMS segment = one unit. A message reserves its segments when queued, is counted with the parts the
modem actually used when sent, and the hold is released if it fails, expires or is cancelled - you never use up
what was not sent.
GET /api/v1/billing/ledgeris the full movement log (CREDIT,RESERVE,CAPTURE,RELEASE,ADJUSTMENT). - When the plan has too few SMS left you get
402witherrorCodePLAN_LIMIT_REACHED; with no active plan,PLAN_REQUIRED(the legacy/send-smsanswers402with the same value instate). A batch is all-or-nothing against the plan. GET /api/v1/billing/invoices(filterstatus=UNPAID|PAID|CANCELLED) andGET /api/v1/billing/invoices/{id}list your invoices and their lines. Set your company details withPUT /api/v1/billing/profile(login only, not an API key); they are copied onto each invoice when it is issued.- A
plan.credits_lowwebhook fires once when your remaining SMS drop under the platform threshold, until the plan is renewed or topped up. GET /api/v1/usage?granularity=day(and/usage/exportas CSV) reports sent / failed / segments /chargedUnits.
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.