API reference
Messaging API 1.0.0, rendered from a snapshot of the service's own OpenAPI document. The authoritative, always-current copy is https://api.pombity.com/openapi/v1.json; it carries the full error-code catalog in its description and examples on every operation.
Base URL: https://api.pombity.com. Auth: the X-Api-Key header on every request. MCP mount: https://api.pombity.com/mcp (same key as a bearer token). Sends accept Idempotency-Key. The rules behind every refusal: the compliance layer.
Operations
GET /v1/healthPOST /v1/messagesGET /v1/messagesPOST /v1/messages/previewGET /v1/messages/{id}POST /v1/emailsPOST /v1/keysGET /v1/keysDELETE /v1/keys/{id}GET /v1/usageGET /v1/tenantsGET /v1/tenants/{id}PUT /v1/tenants/{id}GET /v1/numbers/{number}PUT /v1/numbers/{number}GET /v1/domainsGET /v1/domains/{name}PUT /v1/domains/{name}POST /v1/domains/{name}/verifyPOST /v1/webhooks/acs/deliveryPOST /v1/webhooks/acs/email-deliveryPOST /v1/webhooks/acs/inboundPOST /v1/consentsGET /v1/consentsGET /v1/templatesPUT /v1/templates/{id}DELETE /v1/templates/{id}GET /v1/keyword-callbacksPUT /v1/keyword-callbacks/{keyword}DELETE /v1/keyword-callbacks/{keyword}POST /v1/resident-actionsPOST /v1/verified-destinationsGET /v1/verified-destinationsPOST /v1/verified-destinations/confirmDELETE /v1/verified-destinations/{phone}POST /v1/sandbox/inboundGET /v1/audit
GET /v1/health
Health check
Anonymous liveness probe.
Response 200 (example)
{
"status": "ok",
"service": "messaging"
}
POST /v1/messages
Send an SMS to one or more recipients
Creates one message per recipient sharing a correlationId. Consent is enforced (no opt-in on record, or an opt-out, yields a Blocked row and no carrier traffic); tenant quiet hours hold non-exempt sends; Emergency overrides quiet hours only when the tenant has enabled it. Provide exactly one of body or templateId. Supports the Idempotency-Key header (see the API description). A sandbox key sends through the in-process simulator (rows flagged, Delivered receipt, zero carrier traffic); a verified-only tenant's live sends refuse unverified recipients with destination_not_verified.
Request body (example)
{
"tenantId": "fleet",
"to": [
"+14785551234"
],
"body": "Pothole crew dispatched to Main St.",
"category": "Alert"
}
Response 200 (example)
{
"correlationId": "7f3b2c90a1e14bd3",
"messages": [
{
"id": "0ac87ba2f29b4d1d9d6f0d6c0b7f3a21",
"to": "+14785551234",
"channel": "Sms",
"subject": null,
"status": "Sent",
"statusDetail": null,
"segments": 1,
"providerMessageId": "acs-msg-1",
"deliveryStatus": null,
"correlationId": "7f3b2c90a1e14bd3",
"createdAt": "2026-11-02T15:04:05+00:00",
"sandbox": false
}
]
}
Error responses: 400, 409, 429; every error body carries a catalog code.
GET /v1/messages
List messages
The tenant's message log, filterable by correlationId and time range; format=csv returns a CSV export.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes | |
correlationId | query | no | |
from | query | no | |
to | query | no | |
format | query | no | |
mode | query | no |
Response 200 (example)
[
{
"id": "0ac87ba2f29b4d1d9d6f0d6c0b7f3a21",
"to": "+14785551234",
"channel": "Sms",
"subject": null,
"status": "Sent",
"statusDetail": null,
"segments": 1,
"providerMessageId": "acs-msg-1",
"deliveryStatus": null,
"correlationId": "7f3b2c90a1e14bd3",
"createdAt": "2026-11-02T15:04:05+00:00",
"sandbox": false
}
]
POST /v1/messages/preview
Preview a send without sending
What the send WOULD do: segment count for the resolved body and, per recipient, whether consent would block it. Read-only — no provider call, no rows, no audit.
Request body (example)
{
"tenantId": "fleet",
"to": [
"+14785551234"
],
"body": "Pothole crew dispatched to Main St."
}
Response 200 (example)
{
"segments": 1,
"recipients": 1,
"consentBlocked": 0,
"invalidRecipients": 0
}
Error responses: 400; every error body carries a catalog code.
GET /v1/messages/{id}
Read one message and its delivery report
The full message record including every delivery receipt received so far — poll this after a send to see carrier delivery.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes | |
tenantId | query | yes |
Response 200 (example)
{
"id": "0ac87ba2f29b4d1d9d6f0d6c0b7f3a21",
"tenantId": "fleet",
"to": "+14785551234",
"channel": "Sms",
"subject": null,
"body": "Pothole crew dispatched to Main St.",
"category": "Alert",
"status": "Sent",
"statusDetail": null,
"segments": 1,
"templateId": null,
"correlationId": "7f3b2c90a1e14bd3",
"providerMessageId": "acs-msg-1",
"deliveryStatus": "Delivered",
"deliveryStatusAt": "2026-11-02T15:04:11+00:00",
"createdAt": "2026-11-02T15:04:05+00:00",
"deliveries": [
{
"status": "Delivered",
"receivedAt": "2026-11-02T15:04:11+00:00",
"detail": null
}
]
}
Error responses: 404; every error body carries a catalog code.
POST /v1/emails
Send an email to one or more recipients
Sender identity comes from the tenant (localPart@sendingDomain, platform default domain when the tenant has none). An opt-out is honored; email is never held by quiet hours. Supports the Idempotency-Key header.
Request body (example)
{
"tenantId": "fleet",
"to": [
"ops@example.com"
],
"subject": "Nightly fleet report",
"body": "All vehicles accounted for.",
"category": "Informational"
}
Response 200 (example)
{
"correlationId": "7f3b2c90a1e14bd3",
"messages": [
{
"id": "1bd87ba2f29b4d1d9d6f0d6c0b7f3a22",
"to": "ops@example.com",
"channel": "Email",
"subject": "Nightly fleet report",
"status": "Sent",
"statusDetail": null,
"segments": 0,
"providerMessageId": "acs-email-1",
"deliveryStatus": null,
"correlationId": "7f3b2c90a1e14bd3",
"createdAt": "2026-11-02T15:04:05+00:00",
"sandbox": false
}
]
}
Error responses: 400, 409, 429; every error body carries a catalog code.
POST /v1/keys
Mint an API key (shown once)
Creates a tenant-scoped key. The full key appears only in this response — store it now; every later listing shows only the display prefix. mode is live (default) or sandbox (ak_test_ prefix). A tenant-scoped caller mints only for its own tenant; an unscoped (admin) key mints for any tenant. Rotation = mint here, then DELETE the old key.
Request body (example)
{
"tenantId": "fleet",
"name": "app key",
"mode": "live"
}
Response 200 (example)
{
"id": "9c1f0d6c0b7f3a210ac87ba2f29b4d1d",
"tenantId": "fleet",
"name": "app key",
"mode": "Live",
"key": "ak_live_3f9c…(shown once)",
"displayPrefix": "ak_live_3f9c",
"createdAt": "2026-11-02T15:04:05+00:00"
}
Error responses: 400; every error body carries a catalog code.
GET /v1/keys
List a tenant's keys
Every key minted for the tenant, revoked included — never the key itself or its hash.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes |
Response 200 (example)
[
{
"id": "9c1f0d6c0b7f3a210ac87ba2f29b4d1d",
"tenantId": "fleet",
"name": "app key",
"mode": "Live",
"displayPrefix": "ak_live_3f9c",
"revoked": false,
"createdAt": "2026-11-02T15:04:05+00:00",
"lastUsedAt": "2026-11-02T16:11:42+00:00"
}
]
DELETE /v1/keys/{id}
Revoke a key
Marks the key revoked (the record is kept for the audit trail); it stops authenticating immediately. A key outside the stated tenant answers 404.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes | |
tenantId | query | yes |
Error responses: 404; every error body carries a catalog code.
GET /v1/usage
Read current-period usage, caps, and standing
Counts of sent messages per channel over the UTC calendar month, measured from this service's own log, with the tenant's caps (null = uncapped) and standing (ok | quota_exceeded | account_suspended). The sandbox-SMS allowance has its own cap, separate from the live caps.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes |
Response 200 (example)
{
"tenantId": "fleet",
"period": {
"start": "2026-11-01T00:00:00+00:00",
"end": "2026-12-01T00:00:00+00:00",
"resetsAt": "2026-12-01T00:00:00+00:00"
},
"sms": {
"sent": 12,
"segments": 14,
"cap": 25,
"remaining": 13,
"sandbox": {
"sent": 40,
"segments": 41,
"cap": 5000,
"remaining": 4960
}
},
"email": {
"sent": 3,
"cap": null,
"remaining": null,
"sandbox": {
"sent": 7
}
},
"standing": "ok"
}
Error responses: 404; every error body carries a catalog code.
GET /v1/tenants
List tenants (admin)
Every tenant. Requires an unscoped key.
Response 200 (example)
[
{
"id": "fleet",
"name": "Aric Fleet",
"sendingNumber": "+18442198901",
"timeZone": "America/New_York",
"quietHoursStartHour": 20,
"quietHoursEndHour": 8,
"emergencyOverrideEnabled": true,
"sendingDomain": null,
"emailLocalPart": "alerts",
"emailReplyTo": null,
"inboundPolicy": "none",
"contactPhone": null,
"requireVerifiedDestinations": false,
"smsMonthlyCap": null,
"emailMonthlyCap": null,
"sandboxSmsMonthlyCap": null,
"suspended": false,
"contentScreenEnabled": false
}
]
GET /v1/tenants/{id}
Read one tenant
A tenant-scoped key may read its own record; unscoped keys read any.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes |
Response 200 (example)
{
"id": "fleet",
"name": "Aric Fleet",
"sendingNumber": "+18442198901",
"timeZone": "America/New_York",
"quietHoursStartHour": 20,
"quietHoursEndHour": 8,
"emergencyOverrideEnabled": true,
"sendingDomain": null,
"emailLocalPart": "alerts",
"emailReplyTo": null,
"inboundPolicy": "none",
"contactPhone": null,
"requireVerifiedDestinations": false,
"smsMonthlyCap": null,
"emailMonthlyCap": null,
"sandboxSmsMonthlyCap": null,
"suspended": false,
"contentScreenEnabled": false
}
Error responses: 404; every error body carries a catalog code.
PUT /v1/tenants/{id}
Create or update a tenant (admin)
Merge semantics: unspecified fields keep their current values, so a rename never resets quiet hours. sendingNumber "" explicitly takes the tenant off its number. P6-05 (the platform's entitlement push): smsMonthlyCap, emailMonthlyCap, and sandboxSmsMonthlyCap set the per-channel/per-mode caps (-1 explicitly clears a cap to uncapped); suspended disables every send (account_suspended); contentScreenEnabled turns on the SHAFT/shortener content screen.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes |
Request body (example)
{
"name": "Aric Fleet",
"sendingNumber": "+18442198901",
"timeZone": "America/New_York",
"quietHoursStartHour": 20,
"quietHoursEndHour": 8,
"smsMonthlyCap": 25,
"emailMonthlyCap": 1000,
"sandboxSmsMonthlyCap": 5000,
"suspended": false,
"contentScreenEnabled": true
}
Response 200 (example)
{
"id": "fleet",
"name": "Aric Fleet",
"sendingNumber": "+18442198901",
"timeZone": "America/New_York",
"quietHoursStartHour": 20,
"quietHoursEndHour": 8,
"emergencyOverrideEnabled": true,
"sendingDomain": null,
"emailLocalPart": "alerts",
"emailReplyTo": null,
"inboundPolicy": "none",
"contactPhone": null,
"requireVerifiedDestinations": false,
"smsMonthlyCap": null,
"emailMonthlyCap": null,
"sandboxSmsMonthlyCap": null,
"suspended": false,
"contentScreenEnabled": false
}
Error responses: 400; every error body carries a catalog code.
GET /v1/numbers/{number}
Read a sending number's default tenant (admin)
The explicit default tenant of a shared sending number; unset numbers answer 404 (the default is then the first tenant by id).
| Name | In | Required | Description |
|---|---|---|---|
number | path | yes |
Response 200 (example)
{
"number": "+18442198901",
"defaultTenantId": "pilot-town",
"updatedAt": "2026-11-02T15:04:05+00:00"
}
Error responses: 404; every error body carries a catalog code.
PUT /v1/numbers/{number}
Set a sending number's default tenant (admin)
Reserved keywords and unattributable inbound texts on a shared number route to this tenant, which must be on the number.
| Name | In | Required | Description |
|---|---|---|---|
number | path | yes |
Request body (example)
{
"defaultTenantId": "pilot-town"
}
Response 200 (example)
{
"number": "+18442198901",
"defaultTenantId": "pilot-town",
"updatedAt": "2026-11-02T15:04:05+00:00"
}
Error responses: 400; every error body carries a catalog code.
GET /v1/domains
List sending domains (admin)
The email sending-domain registry.
Response 200 (example)
[
{
"name": "aricalerts.com",
"ownerTenantId": null,
"status": "Verified",
"dnsRecords": [
{
"type": "TXT",
"name": "@",
"value": "ms-domain-verification=…",
"ttl": 3600
}
],
"createdAt": "2026-10-21T00:00:00+00:00",
"verifiedAt": "2026-10-21T12:00:00+00:00"
}
]
GET /v1/domains/{name}
Read one sending domain (admin)
Includes the DNS records to publish and the verification status.
| Name | In | Required | Description |
|---|---|---|---|
name | path | yes |
Response 200 (example)
{
"name": "aricalerts.com",
"ownerTenantId": null,
"status": "Verified",
"dnsRecords": [
{
"type": "TXT",
"name": "@",
"value": "ms-domain-verification=…",
"ttl": 3600
}
],
"createdAt": "2026-10-21T00:00:00+00:00",
"verifiedAt": "2026-10-21T12:00:00+00:00"
}
Error responses: 404; every error body carries a catalog code.
PUT /v1/domains/{name}
Register a sending domain (admin)
Registers the domain with the mail provider and returns the DNS records to publish (verification TXT, SPF, DKIM CNAMEs, DMARC).
| Name | In | Required | Description |
|---|---|---|---|
name | path | yes |
Request body (example)
{
"ownerTenantId": "fleet"
}
Response 200 (example)
{
"name": "aricalerts.com",
"ownerTenantId": null,
"status": "Verified",
"dnsRecords": [
{
"type": "TXT",
"name": "@",
"value": "ms-domain-verification=…",
"ttl": 3600
}
],
"createdAt": "2026-10-21T00:00:00+00:00",
"verifiedAt": "2026-10-21T12:00:00+00:00"
}
Error responses: 400, 502; every error body carries a catalog code.
POST /v1/domains/{name}/verify
Check a sending domain's verification (admin)
Re-checks DNS verification at the provider; on the flip to Verified the default sender identity is ensured.
| Name | In | Required | Description |
|---|---|---|---|
name | path | yes |
Response 200 (example)
{
"name": "aricalerts.com",
"ownerTenantId": null,
"status": "Verified",
"dnsRecords": [
{
"type": "TXT",
"name": "@",
"value": "ms-domain-verification=…",
"ttl": 3600
}
],
"createdAt": "2026-10-21T00:00:00+00:00",
"verifiedAt": "2026-10-21T12:00:00+00:00"
}
Error responses: 404, 502; every error body carries a catalog code.
POST /v1/webhooks/acs/delivery
Event Grid: SMS delivery reports (internal)
Azure Communication Services delivery receipts arrive here via Event Grid; not for API callers. Guarded by the webhook secret, not X-Api-Key.
Request body (example)
[
{
"eventType": "Microsoft.Communication.SMSDeliveryReportReceived",
"data": {
"messageId": "acs-msg-1",
"deliveryStatus": "Delivered",
"receivedTimestamp": "2026-11-02T15:04:11Z",
"tag": "0ac87ba2f29b4d1d9d6f0d6c0b7f3a21"
}
}
]
Response 200 (example)
{
"validationResponse": "<validation-code>"
}
POST /v1/webhooks/acs/email-delivery
Event Grid: email delivery reports (internal)
Azure Communication Services email delivery reports, correlated by the send operation id; not for API callers.
Request body (example)
[
{
"eventType": "Microsoft.Communication.EmailDeliveryReportReceived",
"data": {
"messageId": "acs-email-1",
"status": "Delivered",
"deliveryAttemptTimestamp": "2026-11-02T15:04:11Z"
}
}
]
Response 200 (example)
{
"validationResponse": "<validation-code>"
}
POST /v1/webhooks/acs/inbound
Event Grid: inbound SMS (internal)
Inbound texts (STOP/HELP/START, registered keywords, everything else) arrive here via Event Grid, deduplicated by provider message id; not for API callers.
Request body (example)
[
{
"eventType": "Microsoft.Communication.SMSReceived",
"data": {
"from": "+14785551234",
"to": "+18442198901",
"message": "STOP",
"receivedTimestamp": "2026-11-02T15:04:11Z",
"messageId": "acs-in-1"
}
}
]
Response 200 (example)
{
"validationResponse": "<validation-code>"
}
POST /v1/consents
Record a consent change
Appends to the consent ledger (never overwrites history) and audits the change. status: OptedIn | OptedOut; source: Keyword | WebForm | Manual | CsvImport. Self-verified consents are recorded automatically by POST /v1/verified-destinations/confirm (campaign "self-verified"), not here.
Request body (example)
{
"tenantId": "fleet",
"phone": "+14785551234",
"status": "OptedIn",
"source": "Manual",
"campaign": "fleet-ops"
}
Response 200 (example)
{
"phone": "+14785551234",
"status": "OptedIn",
"source": "Manual",
"occurredAt": "2026-11-02T15:04:05+00:00",
"campaign": "fleet-ops",
"note": null
}
Error responses: 400; every error body carries a catalog code.
GET /v1/consents
Read consent state
Without phone: every number's current status for the tenant. With phone: the current record, or the full history with history=true.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes | |
phone | query | no | |
history | query | no |
Response 200 (example)
[
{
"phone": "+14785551234",
"status": "OptedIn",
"source": "Manual",
"occurredAt": "2026-11-02T15:04:05+00:00",
"campaign": "fleet-ops",
"note": null
}
]
Error responses: 404; every error body carries a catalog code.
GET /v1/templates
List a tenant's message templates
Templates referenced by templateId on sends; merge fields use {Name} placeholders.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes |
Response 200 (example)
[
{
"id": "fleet-check-engine",
"name": "Check engine",
"body": "Aric Fleet: {Asset} check-engine ON…"
}
]
PUT /v1/templates/{id}
Create or update a template
Upserts the tenant's template under the given id.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes | |
tenantId | query | yes |
Request body (example)
{
"name": "Road closure",
"body": "{Tenant}: {Street} is closed until {Time}."
}
Response 200 (example)
{
"id": "road-closure"
}
Error responses: 400; every error body carries a catalog code.
DELETE /v1/templates/{id}
Delete a template
Removes the tenant's template.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes | |
tenantId | query | yes |
Error responses: 404; every error body carries a catalog code.
GET /v1/keyword-callbacks
List keyword callbacks
The tenant's registered inbound keywords and their callback URLs.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes |
Response 200 (example)
[
{
"keyword": "ACK",
"callbackUrl": "https://alerts.example/ack"
}
]
PUT /v1/keyword-callbacks/{keyword}
Register a keyword callback
Inbound texts carrying the keyword dispatch to the callback URL, signed with HMAC-SHA256 (X-Aric-Signature). A keyword is unique per sending number across tenants (P1-04): a conflict is a 409 and nothing is written.
| Name | In | Required | Description |
|---|---|---|---|
keyword | path | yes | |
tenantId | query | yes |
Request body (example)
{
"callbackUrl": "https://alerts.example/ack",
"secret": "<shared-hmac-secret>"
}
Response 200 (example)
{
"keyword": "ACK"
}
Error responses: 400, 409; every error body carries a catalog code.
DELETE /v1/keyword-callbacks/{keyword}
Remove a keyword callback
Unregisters the tenant's keyword.
| Name | In | Required | Description |
|---|---|---|---|
keyword | path | yes | |
tenantId | query | yes |
Error responses: 404; every error body carries a catalog code.
POST /v1/resident-actions
Report a resident's own action
Records an action the service cannot see (e.g. a web sign-up) so the next single-recipient send to that phone within 15 minutes rides the quiet-hours response exemption. Inbound texts are recorded automatically.
Request body (example)
{
"tenantId": "pilot-town",
"phone": "+14785551234",
"source": "web-signup"
}
Response 200 (example)
{
"tenantId": "pilot-town",
"phone": "+14785551234",
"occurredAt": "2026-11-02T15:04:05+00:00"
}
Error responses: 400; every error body carries a catalog code.
POST /v1/verified-destinations
Request a destination verification code
Texts a 6-digit code to the number (expires in 10 minutes). US and Canada numbers only; verification sends are capped per tenant per UTC day and per destination number per UTC day, and a free tenant holds at most 3 verified destinations — each refusal names its code. An already-verified number answers already-verified and sends nothing.
Request body (example)
{
"tenantId": "my-tenant",
"phone": "+14785551234"
}
Response 200 (example)
{
"tenantId": "my-tenant",
"status": "code-sent",
"expiresAt": "2026-11-02T15:14:05+00:00"
}
Error responses: 400, 502; every error body carries a catalog code.
GET /v1/verified-destinations
List the tenant's verified destinations
Every number the tenant's key holder has verified.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes |
Response 200 (example)
[
{
"phone": "+14785551234",
"verifiedAt": "2026-11-02T15:05:11+00:00"
}
]
POST /v1/verified-destinations/confirm
Confirm a verification code
Proves you control the number. On success the destination is verified, consent is recorded in the ledger as self-verified, and a send to this number is never held by quiet hours. Wrong codes are limited to 5 attempts; codes expire after 10 minutes.
Request body (example)
{
"tenantId": "my-tenant",
"phone": "+14785551234",
"code": "123456"
}
Response 200 (example)
{
"tenantId": "my-tenant",
"phone": "+14785551234",
"verifiedAt": "2026-11-02T15:05:11+00:00"
}
Error responses: 400; every error body carries a catalog code.
DELETE /v1/verified-destinations/{phone}
Remove a verified destination
Frees one of the tenant's verified-destination slots; live sends to the number then refuse again while the tenant is verified-only.
| Name | In | Required | Description |
|---|---|---|---|
phone | path | yes | |
tenantId | query | yes |
Error responses: 404; every error body carries a catalog code.
POST /v1/sandbox/inbound
Simulate an inbound text (sandbox keys only)
Drives the real inbound pipeline for your tenant with a simulated text from a number — a topic keyword, YES, STOP, HELP, or a plain sentence — so the whole opt-in flow is exercisable without a carrier. Consent writes are real (one ledger, one set of rules) but never touch co-tenants on a shared number; replies go through the sandbox provider. Live keys are refused with sandbox_key_required.
Request body (example)
{
"tenantId": "my-tenant",
"from": "+14785551234",
"body": "STOP"
}
Response 200 (example)
{
"outcome": "OptedOut",
"from": "+14785551234"
}
Error responses: 400, 403; every error body carries a catalog code.
GET /v1/audit
Query the audit log
The tenant's append-only audit trail, filterable by time range and action; format=csv returns a CSV export.
| Name | In | Required | Description |
|---|---|---|---|
tenantId | query | yes | |
from | query | no | |
to | query | no | |
action | query | no | |
format | query | no |
Response 200 (example)
[
{
"id": "5f2a…",
"occurredAt": "2026-11-02T15:04:05+00:00",
"actor": "app key",
"action": "message.send",
"details": "{…}"
}
]