openapi: 3.0.3
info:
title: MAAC Go API
version: "1.0.0"
description: |
MAAC Go — Taiwan-first self-serve SMS platform. Send transactional (OTP /
order / reminder) and broadcast SMS with NCC compliance, MAAC delivery
backend, and real-time DLR webhooks.
- Base URL: `https://sms.cresclab.com/api`
- Auth: `Authorization: Bearer <sk_live_... or sk_test_...>`
- New accounts receive NT$50 trial credit on signup, enough to send real test SMS immediately.
- API keys are scoped and wallet-backed; sending debits the account balance unless your deployment uses a mock SMS/payment adapter.
- All phone numbers use E.164 (`+886912345678`). 09xxxxxxxx is also accepted.
- Cost: NT$0.78 per SMS segment (Chinese: 70 chars/segment · English: 160/segment).
- Rate-limit errors return `429 rate_limited` with limit/used details and retry guidance when available.
- Delivery confirmations arrive asynchronously via webhook (`sms.delivered`
/ `sms.failed`); poll `GET /sms/{id}` as a fallback.
- MCP clients (Claude / Cursor / Windsurf) can use this spec directly, or
connect via `POST /api/mcp` (JSON-RPC 2.0) with the same bearer key.
contact:
name: MAAC Go Support
email: info@cresclab.com
url: https://sms.cresclab.com
license:
name: Commercial
url: https://sms.cresclab.com/terms.html
servers:
- url: https://sms.cresclab.com/api
description: Production
security:
- bearerAuth: []
tags:
- name: SMS
description: Transactional (1-to-1) SMS send + status.
- name: Broadcast
description: Bulk (1-to-many) SMS. Small batches dispatch inline; >30 recipients queue asynchronously.
- name: Contacts
description: Address book with NCC-consent tracking.
- name: Webhooks
description: Delivery events pushed to your endpoint (HMAC-SHA256 signed).
- name: Wallet
description: Prepaid balance and transaction log.
- name: Teams
description: Cost-attribution teams. Tag sends with a team to see which team sent/spent how much (shared wallet — reporting only, not a wallet split).
paths:
/sms/send:
post:
tags: [SMS]
summary: Send a transactional SMS (1-to-1)
description: |
OTP, order notifications, appointment reminders. High-priority channel,
no LINE fallback. Returns after MAAC accepts the request; final delivery
status arrives via the `sms.delivered` / `sms.failed` webhook.
operationId: sendSms
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SmsSendRequest' }
examples:
otp:
value: { to: "+886912345678", body: "【YourBrand】驗證碼 483291,5 分鐘內有效。勿告知他人。", type: "otp" }
notification:
value: { to: "+886912345678", body: "【YourBrand】訂單 A12345 已出貨,預計 3/15 送達。", type: "notification" }
responses:
"200":
description: Queued for delivery
content:
application/json:
schema: { $ref: '#/components/schemas/SmsSendResponse' }
"400":
description: Validation or NCC compliance failure
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
examples:
ncc:
value: { error: "ncc_blocked", issues: [{ level: "block", code: "SHORTENER", reason: "bit.ly is on NCC 2024/11 blocklist" }] }
"401": { description: "Auth missing / invalid", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
"402": { description: "Wallet balance insufficient", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
"429": { description: "Rate limit exceeded", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
/sms/{id}:
get:
tags: [SMS]
summary: Retrieve SMS status
description: Poll once per 5+ seconds as a fallback; prefer the `sms.delivered` webhook.
operationId: getSms
parameters:
- in: path
name: id
required: true
schema: { type: string }
example: "sms_abc123"
responses:
"200":
description: Current message state
content:
application/json:
schema: { $ref: '#/components/schemas/SmsDetail' }
"404": { description: "Not found" }
/sms/list:
get:
tags: [SMS]
summary: List recent messages (Resend-style delivery log)
operationId: listSms
parameters:
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
- in: query
name: status
schema: { type: string, enum: [queued, sent, delivered, failed, stop] }
- in: query
name: team
description: Filter to one cost-attribution team. Use the literal `(未分類)` for untagged messages.
schema: { type: string }
example: "付費會員"
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
messages: { type: array, items: { $ref: '#/components/schemas/SmsDetail' } }
summary_7d: { type: object, additionalProperties: { type: integer } }
/sms/by-team:
get:
tags: [SMS]
summary: Cost attribution per team (messages / cost / delivered / failed)
description: |
Returns one row per team plus `(未分類)` for untagged sends, so the rows sum to the
account total. Money is shared (one wallet, one invoice per 統編); this is a reporting
breakdown for internal chargeback, **not** a wallet split.
Windowing: pass `from`/`to` for an exact billing period (monthly chargeback), or `days`
for a rolling window. `from`/`to` take precedence when supplied.
operationId: smsByTeam
parameters:
- in: query
name: days
description: Rolling window in days (ignored if from/to given).
schema: { type: integer, default: 30, maximum: 90 }
- in: query
name: from
description: Start of an exact range (ISO date/datetime, inclusive).
schema: { type: string, format: date-time }
example: "2026-06-01"
- in: query
name: to
description: End of an exact range (ISO date/datetime, exclusive).
schema: { type: string, format: date-time }
example: "2026-07-01"
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
days: { type: integer, description: "Present in rolling-window mode" }
from: { type: string, nullable: true, description: "Present in range mode" }
to: { type: string, nullable: true, description: "Present in range mode" }
total_cost_cents: { type: integer }
teams:
type: array
items:
type: object
properties:
team: { type: string, description: "Team name, or (未分類) for untagged" }
msgs: { type: integer }
cost_cents: { type: integer }
delivered: { type: integer }
failed: { type: integer }
/teams:
get:
tags: [Teams]
summary: List cost-attribution teams (+ lifetime usage)
operationId: listTeams
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
teams:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
archived: { type: boolean }
created_at: { type: string, format: date-time }
msg_count: { type: integer }
cost_cents: { type: integer }
post:
tags: [Teams]
summary: Create a team (idempotent on name; re-creates un-archive)
description: |
Teams are also auto-created on first use via `team` on `/sms/send` or `/broadcast`,
so calling this explicitly is optional — handy for pre-seeding before you send.
operationId: createTeam
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, example: "付費會員" }
responses:
"200":
description: Created (or already existed)
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
team:
type: object
properties:
id: { type: integer }
name: { type: string }
archived: { type: boolean }
created_at: { type: string, format: date-time }
delete:
tags: [Teams]
summary: Archive a team (soft-delete; history is retained)
operationId: deleteTeam
parameters:
- in: query
name: id
required: true
schema: { type: integer }
responses:
"200": { description: Archived }
/sms/metrics:
get:
tags: [SMS]
summary: Daily volume + delivery metrics
operationId: getMetrics
parameters:
- in: query
name: days
schema: { type: integer, default: 30, maximum: 90 }
responses:
"200":
description: Aggregated metrics
content:
application/json:
schema: { $ref: '#/components/schemas/MetricsResponse' }
/broadcast:
post:
tags: [Broadcast]
summary: Create a broadcast (1-to-many)
description: |
≤ 30 recipients dispatch inline and return 200 with `delivered` / `failed` counts.
\> 30 recipients queue async and return 202 with `status: "queued"`; cron dispatches in
batches of 40 every 5 min; DLR webhooks flip individual messages.
Schedule for later by passing `scheduled_at` (ISO 8601); funds reserve immediately,
actual charge happens at send time.
operationId: createBroadcast
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/BroadcastCreateRequest' }
responses:
"200":
description: Sent inline (small broadcast)
content:
application/json:
schema: { $ref: '#/components/schemas/BroadcastSentResponse' }
"202":
description: Queued for async dispatch (large broadcast)
content:
application/json:
schema: { $ref: '#/components/schemas/BroadcastQueuedResponse' }
"400": { description: "recipients_required / invalid_phones / ncc_blocked / too_many_recipients" }
"402": { description: "insufficient_balance" }
"429": { description: "rate_limited" }
get:
tags: [Broadcast]
summary: List broadcasts with per-broadcast delivery stats
operationId: listBroadcasts
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
broadcasts: { type: array, items: { $ref: '#/components/schemas/BroadcastSummary' } }
/contacts:
get:
tags: [Contacts]
summary: List contacts
operationId: listContacts
parameters:
- { in: query, name: search, schema: { type: string } }
- { in: query, name: tag, schema: { type: string } }
- { in: query, name: page, schema: { type: integer, default: 1 } }
- { in: query, name: limit, schema: { type: integer, default: 50, maximum: 500 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
data: { type: array, items: { $ref: '#/components/schemas/Contact' } }
total: { type: integer }
page: { type: integer }
limit: { type: integer }
post:
tags: [Contacts]
summary: Create a contact (single) or bulk
description: |
Single mode: `{ phone, first_name?, last_name?, tags?, consent_source? }`
Bulk mode: `{ contacts: [...], consent_source? }` — up to 10,000 per call.
`consent_source` is required for NCC compliance. Duplicate phones within a batch
are deduped; existing `(user_id, phone)` pairs are skipped.
operationId: createContact
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/ContactCreateSingle'
- $ref: '#/components/schemas/ContactCreateBulk'
responses:
"201":
description: Created (single or bulk stats)
webhooks:
sms.delivered:
post:
summary: SMS reached the recipient's handset
description: |
Delivered to your webhook endpoint when MAAC's DLR confirms delivery.
Sign with HMAC-SHA256: `X-Cresclab-Signature: <hex_digest>` where
`hex_digest = HMAC_SHA256(your_webhook_secret, raw_request_body)`.
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookEvent' }
responses:
"200": { description: "ACK with any 2xx" }
sms.failed:
post:
summary: Final delivery failure (number invalid / blocked / undeliverable)
description: Wallet is auto-refunded for failed messages.
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookEvent' }
responses:
"200": { description: "ACK with any 2xx" }
sms.sent:
post:
summary: Message accepted by carrier (interim state)
requestBody:
content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEvent' } } }
responses:
"200": { description: "ACK" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: |
`Authorization: Bearer <api_key>`. Production keys start with `sk_live_`,
test keys with `sk_test_` for environment separation. Sending still follows
the account wallet and deployment adapter settings.
schemas:
SmsSendRequest:
type: object
required: [to, body]
properties:
to:
type: string
description: E.164 (`+886912345678`) or TW local (`0912345678`)
example: "+886912345678"
body:
type: string
maxLength: 1000
description: SMS text. 70 chars = 1 segment (Chinese), 160 (ASCII). Must include `【brand】` prefix and `STOP` / `退訂` for marketing.
from:
type: string
description: Sender ID. Defaults to Cresclab shared short-code `1990`.
type:
type: string
enum: [otp, notification, marketing]
description: Controls routing priority and compliance checks.
team:
type: string
description: >-
Cost-attribution team for this send (e.g. `付費會員`). Overrides the API key's
bound team. An unknown name is auto-created, so a typo surfaces as a stray team
rather than vanishing. Omit to inherit the key's team or fall to 未分類.
example: "付費會員"
SmsSendResponse:
type: object
properties:
ok: { type: boolean }
message_id: { type: string, example: "sms_abc123" }
status: { type: string, enum: [queued, sent, delivered, failed] }
segments: { type: integer }
cost_cents: { type: integer, description: "Amount debited in NT cents (78 = NT$0.78)" }
balance_cents: { type: integer }
team: { type: string, nullable: true, description: "Resolved cost-attribution team (null = 未分類). Echo of what was tagged." }
team_created: { type: boolean, description: "True if this send auto-created the team (first use of the name)." }
SmsDetail:
type: object
properties:
id: { type: string }
to: { type: string }
body: { type: string }
status: { type: string, enum: [queued, sent, delivered, failed, stop] }
segments: { type: integer }
cost_cents: { type: integer }
sent_at: { type: string, format: date-time, nullable: true }
delivered_at: { type: string, format: date-time, nullable: true }
error: { type: string, nullable: true }
gateway_ref: { type: string, nullable: true, description: "Upstream MAAC pnp_message_id" }
BroadcastCreateRequest:
type: object
required: [body, recipients]
properties:
name: { type: string, description: "Internal label for the campaign" }
body: { type: string, description: "Same NCC rules as /sms/send" }
recipients:
type: array
items: { type: string, description: "E.164 or 09xxxxxxxx" }
minItems: 1
maxItems: 10000
scheduled_at:
type: string
format: date-time
description: "ISO 8601. Omit to send immediately."
from: { type: string }
team:
type: string
description: >-
Cost-attribution team for the whole broadcast (e.g. `電商`). Overrides the API
key's bound team; an unknown name is auto-created.
example: "電商"
BroadcastSentResponse:
type: object
properties:
ok: { type: boolean }
broadcast_id: { type: string }
status: { type: string, enum: [sent] }
recipient_count: { type: integer }
delivered: { type: integer, description: "Accepted by carrier — final status via webhook" }
failed: { type: integer }
cost_cents: { type: integer }
refund_cents: { type: integer }
balance_cents: { type: integer }
team: { type: string, nullable: true, description: "Resolved cost-attribution team (null = 未分類)." }
team_created: { type: boolean, description: "True if this broadcast auto-created the team." }
BroadcastQueuedResponse:
type: object
properties:
ok: { type: boolean }
broadcast_id: { type: string }
status: { type: string, enum: [queued] }
recipient_count: { type: integer }
cost_cents: { type: integer }
dispatch: { type: string, enum: [async] }
BroadcastSummary:
type: object
properties:
id: { type: string }
name: { type: string }
recipient_count: { type: integer }
cost_cents: { type: integer }
status: { type: string, enum: [scheduled, sending, sent, failed] }
delivered_count: { type: integer }
failed_count: { type: integer }
sent_count: { type: integer }
created_at: { type: string, format: date-time }
scheduled_at: { type: string, format: date-time, nullable: true }
finished_at: { type: string, format: date-time, nullable: true }
Contact:
type: object
properties:
id: { type: integer }
phone: { type: string, description: "09xxxxxxxx (normalized)" }
first_name: { type: string, nullable: true }
last_name: { type: string, nullable: true }
tags: { type: array, items: { type: string } }
consent_source: { type: string, example: "signup_form" }
created_at: { type: string, format: date-time }
ContactCreateSingle:
type: object
required: [phone, consent_source]
properties:
phone: { type: string }
first_name: { type: string }
last_name: { type: string }
tags: { type: array, items: { type: string } }
consent_source:
type: string
description: "Where you collected consent: signup_form / csv_import / api / manual / custom"
ContactCreateBulk:
type: object
required: [contacts]
properties:
consent_source: { type: string, default: "csv_import" }
contacts:
type: array
maxItems: 10000
items:
type: object
required: [phone]
properties:
phone: { type: string }
first_name: { type: string }
last_name: { type: string }
tags: { type: array, items: { type: string } }
MetricsResponse:
type: object
properties:
ok: { type: boolean }
days: { type: integer }
daily:
type: array
items:
type: object
properties:
day: { type: string, format: date }
total: { type: integer }
delivered: { type: integer }
failed: { type: integer }
cost_cents: { type: integer }
totals:
type: object
properties:
total: { type: integer }
delivered: { type: integer }
failed: { type: integer }
cost_cents: { type: integer }
avg_latency_sec: { type: number, nullable: true }
WebhookEvent:
type: object
description: |
Outbound event sent to your configured webhook URL. Verify `X-Cresclab-Signature`
header: `hmac_sha256(your_webhook_secret, raw_body_bytes).hex() == header_value`.
properties:
event: { type: string, enum: [sms.sent, sms.delivered, sms.failed] }
data:
type: object
properties:
id: { type: string, example: "sms_abc123" }
to: { type: string }
status: { type: string }
error: { type: string, nullable: true }
created_at: { type: string, format: date-time }
ErrorResponse:
type: object
properties:
error: { type: string, example: "ncc_blocked" }
hint: { type: string }
issues:
type: array
items:
type: object
properties:
level: { type: string, enum: [block, warn] }
code: { type: string }
reason: { type: string }