Developer docs

Everything you need to send your first message in minutes

Send an SMS
curl -X POST https://api.smswave.com.au/v1/sms \
  -H "Authorization: MAC id=\"<key>\", ts=\"<ts>\", nonce=\"<nonce>\", mac=\"<mac>\"" \
  -H "Content-Type: application/json" \
  -d '{
    "destination": "+61400000000",
    "message": "Hello from SMSWave"
  }'

Uses your account's MAC key/secret — see API keys for a live signed curl example.

SMSWave exposes a REST API with HMAC MAC signing. Live OpenAPI: https://api.smswave.com.au/openapi.yaml (also docs/openapi.yaml in the repo). Admin routes under /v1/admin are omitted from the public contract.

Authentication

  • Dashboard: Authorization: Bearer <jwt> POST /v1/auth/login returns AuthSession or a Login2faChallenge; complete with POST /v1/auth/2fa/verify. Password reset: forgot-password / reset-password. SDK: login, verify2fa, signup, forgotPassword.
  • Customer API: Authorization: MAC id="key", ts="…", nonce="…", mac="…"

API credentials are key and secret (create under API keys). Prefer the SDK MAC client; the API keys page also shows a live signed curl after you create a key.

# Manage keys (JWT / owner) — SDK: listApiKeys, createApiKey, deleteApiKey
POST /v1/api-keys  { "name": "Production" }  → key + secret once
GET  /v1/api-keys
DELETE /v1/api-keys/{id}

# npm i @smswave/sdk
import { SmsWaveClient } from '@smswave/sdk';

const client = new SmsWaveClient({
  baseUrl: 'https://api.smswave.com.au',
  mac: {
    keyId: process.env.SMSWAVE_KEY_ID!,
    secret: process.env.SMSWAVE_SECRET!,
  },
});

const account = await client.getAccount();
await client.sendSms({ destination: '+61400000000', message: 'Hello' });
await client.sendMms({
  destination: '+61400000000',
  attachments: [{ name: 'a.jpg', contentType: 'image/jpeg', dataBase64: '…' }],
});

# Low-level signer still available: import { buildMacHeader } from '@smswave/sdk/mac'

Send SMS

POST /v1/sms
Idempotency-Key: my-retry-key   # optional; same key returns original result
{ "destination": "+61400000000", "message": "Hello",
  "sendAt": "2026-07-22T12:00:00Z",
  "expiresAt": "2026-07-22T18:00:00Z" }

→ 202 SendSmsResponse { "id": "…", "status": "queued", "scheduledFor": "…", "expiresAt": "…" }

POST /v1/sms/{id}/cancel   → CancelMessageResult (refundAmount, quotaSegmentsRestored, paygSegmentsRestored)
POST /v1/sms/batch
{ "destinations": ["+61…"], "message": "Hi {{Given Name}}",
  "messageByDestination": { "+61400000000": "Hi Alex" } }
→ 202 BatchSendResult { batchId, queued[], failed[], totals? }
# SDK: sendSmsBatch → BatchSendResultDto
GET  /v1/sms/batches?limit=30
GET  /v1/sms/batches/{batchId}/summary
POST /v1/sms/batches/{batchId}/cancel  → CancelBatchResult (refund totals + paygSegmentsRestored + failed[])
# SDK: listBatches, getBatchSummary, cancelBatch
GET  /v1/sms?batchId=…&status=failed,undelivered&channel=mms&contact=+61…&cursor=…
GET  /v1/sms/export?batchId=…&status=problem&channel=sms&days=30
GET  /v1/sms/threads?limit=40&q=…
GET  /v1/sms/inbox/unread              → { unreadThreads }
GET  /v1/sms/scheduled/summary         → { scheduled }
POST /v1/sms/threads/read              { "contactKey": "+61…" }
POST /v1/sms/threads/read-all          → { ok, marked }
# SDK: listMessages, exportMessagesCsv, listThreads, getInboxUnread,
#      getScheduledSummary, markThreadRead, markAllThreadsRead
POST /v1/whatsapp          → 501 until WhatsApp Business is wired

Per-account enqueue limit: smsPerMinute (default ACCOUNT_SMS_PER_MINUTE). Exceeding returns 429. Outbound messages carry a carrierMessageId once the network accepts them; GET /health reports carrier: ok when a live route is configured. Batch sends accept optional messageByDestination for per-recipient bodies (e.g. merge fields).

Send MMS

POST /v1/mms
Idempotency-Key: optional
{ "destination": "+61400000000",
  "message": "Caption (optional)",
  "subject": "Optional subject",
  "attachments": [
    { "name": "photo.jpg", "contentType": "image/jpeg", "dataBase64": "…" }
  ] }

POST /v1/mms/batch
{ "destinations": ["+61…"],
  "attachments": […],
  "message": "Hi {{Given Name}}",
  "subject": "For {{Given Name}}",
  "messageByDestination": { "+61400000000": "Hi Alex" },
  "subjectByDestination": { "+61400000000": "For Alex" } }
→ 202 BatchSendResult (same shape as SMS batch)
# SDK: sendMmsBatch → BatchSendResultDto

# Limits: 1–15 attachments, each ≤300KB decoded
# List campaigns: POST /v1/contact-lists/{id}/send with channel=mms
#   (server resolves caption + subject merge fields per contact)

Shared attachments across destinations. Optional messageByDestination / subjectByDestination override caption and subject per recipient (same merge pattern as SMS batch). URL shortener rewrites http(s) links in captions and subjects when enabled.

Templates

GET  /v1/templates
POST /v1/templates              → CreateMessageTemplate
# SMS: { "name": "Welcome", "channel": "sms", "body": "Hi {{Given Name}}" }
# MMS: { "name": "Flyer", "channel": "mms", "subject": "For {{Given Name}}",
#        "body": "Optional caption",
#        "attachments": [
#          { "name": "photo.jpg", "contentType": "image/jpeg", "dataBase64": "…" }
#        ] }
GET  /v1/templates/{id}
PATCH /v1/templates/{id}        → UpdateMessageTemplate
DELETE /v1/templates/{id}       → OkResult

# SDK: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
# List omits attachment dataBase64 — GET /v1/templates/{id} returns full media
# Dashboard: Templates page, Send / Conversation / list campaign Load & Save

SMS templates require body. MMS templates require at least one attachment (same limits as send: 1–15, ≤300KB each); caption and subject are optional. Existing templates default to channel: sms. List responses include attachment name/contentType only; fetch a template by id before composing or editing MMS media.

Contact lists

GET  /v1/contact-lists
POST /v1/contact-lists  { "name": "Customers", "visibility": "team" }
GET  /v1/contact-lists/{id}
PATCH /v1/contact-lists/{id}  { "name": "…", "visibility": "private" }
DELETE /v1/contact-lists/{id}

GET  /v1/contact-lists/search?q=alex&limit=20
GET  /v1/contact-lists/{id}/contacts?limit=50&cursor=…
POST /v1/contact-lists/{id}/contacts
{ "msisdn": "+61400000000", "givenName": "Alex", "familyName": "Lee" }
PATCH /v1/contact-lists/{id}/contacts/{contactId}
DELETE /v1/contact-lists/{id}/contacts/{contactId}  # SDK: removeContact

POST /v1/contact-lists/{id}/import  { "csv": "msisdn,givenName\n+61…,Alex" }
GET  /v1/contact-lists/{id}/export  → text/csv

POST /v1/contact-lists/{id}/send
Idempotency-Key: optional
{ "channel": "sms", "message": "Hi {{Given Name}}", "origin": "+61…" }
→ 202 ContactListSendResult { listId, batches[], totals, failed[] }
# MMS: channel=mms, attachments required; subject/caption optional
#   merge fields resolve per contact (caption + subject)

# SDK: listContactLists, createContactList, getContactList, updateContactList,
#      deleteContactList, searchContacts, listContacts, addContact, updateContact,
#      removeContact, importContacts, exportContactsCsv, sendToContactList
# Dashboard: Contact lists → Send to list

List send skips destinations on the suppression list. SMS requires message; MMS requires attachments (same limits as POST /v1/mms). Optional sendAt / expiresAt apply to the whole campaign.

Suppressions (opt-outs)

GET  /v1/suppressions?from=2026-01-01&to=2026-12-31&q=+61&sort=date&dir=desc
POST /v1/suppressions  { "destination": "+61400000000", "reason": "manual" }
POST /v1/suppressions/import  { "csv": "+61400000000\n+61411111111" }
DELETE /v1/suppressions/{id}  → re-opt-in (remove block)

# SDK: listSuppressions, addSuppression, importSuppressions, deleteSuppression
# Inbound STOP / STOPALL / … auto-add; START / UNSTOP / … lift the block
# Dashboard: Suppressions; Settings → STOP confirmation message

Outbound enqueue rejects suppressed destinations. Import accepts CSV/TSV/newlines of MSISDNs. Removing a row is a manual re-opt-in; carriers may still honor network-level blocks.

Webhooks (to you)

Events: message.status, message.inbound, account.low_balance, link.click. Each delivery is signed with X-SMSWave-Timestamp and X-SMSWave-Signature: sha256=… over timestamp.body (raw body bytes). Prefer verifyWebhookSignature from @smswave/sdk/webhook (default 5-minute skew). Status payloads include channel, carrierMessageId, errorCode, and errorMessage when known. Inbound payloads include channel, carrierMessageId, and for MMS subject / attachment metadata (full media on message detail). Events fire on carrier DLRs, cancel, permanent send failure, and inbound MO (test samples do too).

import { verifyWebhookSignature } from '@smswave/sdk/webhook';

const ok = verifyWebhookSignature({
  secret: process.env.SMSWAVE_WEBHOOK_SECRET!,
  rawBody, // exact request body string
  timestamp: req.headers['x-smswave-timestamp'],
  signature: req.headers['x-smswave-signature'],
});

POST /v1/webhooks/test              → queue sample event
                                      { "eventType": "message.status", "sample": "mms" }
                                      { "eventType": "message.inbound", "sample": "mms" }
GET  /v1/webhooks/deliveries?status=failed&eventType=message.status&limit=50
GET  /v1/webhooks/deliveries/{id}   → inspect payload
POST /v1/webhooks/deliveries/{id}/retry
POST /v1/webhooks  {
  "url": "…",
  "rotateSecret": false,
  "eventTypes": ["message.status", "message.inbound"]
}
# omit eventTypes (or pass all four) to receive every event

# SDK: getWebhook, upsertWebhook({ eventTypes }), deleteWebhook, sendWebhookTest,
#      listWebhookDeliveries, getWebhookDelivery, retryWebhookDelivery
# Dashboard: Webhooks → endpoint + event checkboxes + delivery history

Analytics

Usage series and ledger are available to all roles. Per-user and per-channel breakdowns are owners/admins only. Outbound by-channel counts use carrier-accepted messages (carrierMessageId set).

GET  /v1/analytics/usage?days=30&scope=overall
GET  /v1/analytics/usage/export?days=30          → text/csv
POST /v1/analytics/usage/rebuild                 → RebuildUsageResult

GET  /v1/analytics/by-user?days=30        → owners/admins
GET  /v1/analytics/by-channel?days=30     → owners/admins (sms / mms / …)
GET  /v1/analytics/by-channel/export      → text/csv
GET  /v1/analytics/clicks?days=30         → short-link click rollups
GET  /v1/analytics/clicks/export?days=30  → text/csv

GET  /v1/analytics/ledger?limit=50
GET  /v1/analytics/ledger/export?limit=1000 → text/csv
GET  /v1/sms/export?…                       → text/csv

# SDK: getUsage, getUsageByChannel, exportUsageByChannelCsv,
#      getShortLinkClicksUsage, exportShortLinkClicksCsv, getLedger, …

Lookup & tools

POST /v1/lookup   { "msisdn": "+61400000000" }
GET /v1/sender-ids
GET /v1/account/number-requests
POST /v1/account/virtual-number-request
POST /v1/account/verified-sender-request  { "senderId": "…", "countries": ["AU"], … }

# SDK: lookupNumber, listSenderIds, listNumberRequests,
#      requestVirtualNumber, requestVerifiedSender
# Dashboard: Tools, Numbers / Virtual & Verified

Account & settings

GET  /v1/account
PATCH /v1/account/sms-settings   → UpdateSmsSettingsRequest → Account
PATCH /v1/account/billing-alerts → UpdateBillingAlertsRequest → Account

# SDK: getAccount, updateSmsSettings, updateBillingAlerts
# 2FA: GET /v1/auth/security; POST …/2fa/totp/*; POST …/2fa/sms/*
# Dashboard: Settings

URL shortener

POST  /v1/short-links         → CreateShortLink → ShortLink
GET   /v1/short-links?limit=50 → ShortLinksPage
GET   /v1/short-links/clicks?limit=50 → ShortLinkClicksPage
GET   /v1/short-links/{id}/clicks
PATCH /v1/short-links/{id}   → UpdateShortLink
# Redirect (public): GET /r/{code}  → fires link.click webhook

# SDK: listShortLinks, createShortLink, updateShortLink,
#      listShortLinkClicks, listShortLinkClicksById
# Dashboard: URL Shortener; Messages/Reports/Conversation short-link panels

Support

POST /v1/support/tickets
{ "category": "technical", "subject": "…", "body": "…" }
GET  /v1/support/tickets
GET  /v1/support/tickets/{id}           → includes messages[] + needsReply
GET  /v1/support/awaiting-reply         → { awaitingReply }
POST /v1/support/tickets/{id}/replies   { "body": "…" }

# SDK: createSupportTicket, listSupportTickets, getSupportTicket,
#      getSupportAwaitingReply, replySupportTicket
# Dashboard: Support (thread + reply + nav badge)

Team

GET  /v1/team/members
POST /v1/team/members  { "email": "…", "password": "…", "role": "member" }
PATCH /v1/team/members/{id}/role  { "role": "admin" }
DELETE /v1/team/members/{id}
GET  /v1/team/invites
POST /v1/team/invites  { "email": "…", "role": "member" }
DELETE /v1/team/invites/{id}
POST /v1/team/accept   { "token": "…", "password": "…" }  # public

# SDK: listTeamMembers, inviteMember, createTeamMember, acceptInvite, …
# Dashboard: Team

Billing model

Monthly plan included segments first, then prepaid balance at overage rate. Paid plans with Stripe use recurring Checkout; renewals arrive via invoice.paid. Manage card: POST /v1/billing/portal. Cancel auto-renew: POST /v1/billing/cancel-recurring.

GET  /v1/billing/info
GET  /v1/billing/plans
GET  /v1/billing/subscription
GET  /v1/billing/payments
GET  /v1/billing/top-up-packages
POST /v1/billing/portal              → BillingPortalSession { url }
POST /v1/billing/top-ups             → BillingCheckoutResult
POST /v1/billing/plan                → BillingCheckoutResult
POST /v1/billing/renew               → BillingCheckoutResult
POST /v1/billing/cancel-recurring    → CancelRecurringResult
POST /v1/billing/payments/confirm-session → PaymentSettlementResult
GET  /v1/auth/profile
PATCH /v1/auth/profile  { "name": "…", "accountName": "…" }

# SDK: getBillingInfo, purchaseTopUp, changePlan, renewSubscription,
#      createBillingPortalSession, confirmCheckoutSession, …
# Dashboard: Billing, Profile
Developer docs · SMSWave