Download OpenAPI specification:
Prepaid SMS API for Tanzanian businesses.
The Sendd REST/JSON API: send SMS to Tanzanian (+255) numbers, follow each message's status, and receive
signed webhooks. Money is in cents (1/100 TZS) unless a field says _tzs (whole shillings). Times are RFC 3339
timestamps with an offset; the dashboard shows them in Africa/Dar_es_Salaam.
Authentication. Server-to-server calls use an API key: Authorization: Bearer sndd_test_... or
sndd_live_.... Each operation lists the key scopes it accepts in x-sendd-scopes ([] means the
operation is for signed-in dashboard members only) and the member permission in x-sendd-permission.
A missing or invalid key is 401 unauthorized; a key without the scope is 403 insufficient_scope.
Delivery honesty. Sendd never claims exactly-once delivery. accepted means Sendd durably
recorded the request; it is not evidence that the provider or handset received anything.
unknown means the provider outcome is ambiguous; Sendd will not resend it automatically.
Errors use one envelope: {"success": false, "error": {"code", "message", "field"?}}. Retry a
429 or 503 after its Retry-After; never retry a 4xx unchanged.
Validates, prices and pays for the message in one step, then returns 202 Accepted: the sender is
checked, segments are counted (GSM-7 / UCS-2, at most 10), the price comes from the organization's
current rates, and the total is held from the wallet. If any check fails, nothing is recorded or held.
Dispatch to the provider happens asynchronously; accepted is not delivered.
Idempotency. X-Idempotency-Key is required. Keys are scoped to tenant, operation and
request fingerprint. Repeating a key with an identical body returns the original response
(header X-Idempotent-Replay: true) and creates no second message or charge. Repeating a key
with a different body returns 409 idempotency_conflict.
Test-mode keys never send to a provider and are never charged (test: true, price.charged: false). Their messages complete at once with a scripted outcome by the recipient's last digit: 1 or 2
failed, 8 undelivered, anything else delivered. In local development and whenever live dispatch
is disabled, no real SMS leaves the platform.
Limits: live and test traffic are limited separately per organization, and one recipient can receive
a bounded number of live messages an hour (429 rate_limited, with Retry-After).
Promotional messages (traffic_class: promotional) go only to a number with a contact whose consent
is recorded (422 consent_required, field to) and no active opt-out (422 recipient_opted_out, field
to). Transactional messages are never refused for either (provisional). A promotional
message accepted while the sending window is closed (GET /v1/sending-window) stays queued, unsent and
uncharged, until the window opens (the response's held_until); if it expires first it is expired and
its hold released. It is accepted, not refused.
The SMS route decides which characters a message may hold and how it is counted
(GET /v1/service-status gives sms_route). On gsm0338 any text is sent, as GSM-7 or, with a
character outside GSM 03.38, as UCS-2. On ascii the body must be printable ASCII plus newline (no
carriage return); every character counts one, 160 for one segment, then 153 per segment. A body with any
other character (accented letters, curly quotes, dashes, emoji, £) is refused with
422 encoding_not_supported (field body), whose details list the characters and their positions.
Sendd never removes or replaces characters: change the text and send again.
Refusals: 403 sender_not_authorized (field from), 403 organization_not_verified,
403 wallet_frozen, 409 no_price_plan, 422 insufficient_funds, 422 too_many_segments,
422 encoding_not_supported, 422 consent_required, 422 recipient_opted_out, 422 validation_failed, 422 sender_not_ready
(field from: Sendd approved the sender ID, but the SMS route has not approved it yet; see the
sender ID's provider_approval).
| X-Idempotency-Key required | string [ 1 .. 255 ] characters ^[\x21-\x7E]+$ Client-generated unique key (for example a UUID), 1-255 printable ASCII characters, not starting with
|
| to required | string A Tanzanian mobile number. |
| from required | string [ 1 .. 11 ] characters A sender ID assigned to the organization and approved for the traffic class (see GET /v1/sender-ids). |
| body required | string [ 1 .. 1600 ] characters Message text, normalized to Unicode NFC. Segments are computed server-side 160 characters (153 per part; extension characters such as € count two), otherwise UCS-2 70 (67 per part); at most 10. Control characters other than newline, carriage return and form feed are refused. |
| client_ref required | string [ 1 .. 128 ] characters Caller correlation reference, echoed in responses and webhooks. Printable characters only. |
| traffic_class | string Default: "transactional" Enum: "transactional" "promotional" |
| ttl | integer [ 1 .. 259200 ] Seconds after which an undispatched message is expired instead of sent (for example OTPs). |
{- "to": "+255712345678",
- "from": "SENDD",
- "body": "Your order #1042 is ready for pickup.",
- "client_ref": "order-1042-ready",
- "ttl": 300
}{- "success": true,
- "data": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "accepted",
- "to": "string",
- "from": "string",
- "body": "string",
- "traffic_class": "transactional",
- "client_ref": "string",
- "encoding": "gsm7",
- "segments": 1,
- "price": {
- "currency": "TZS",
- "unit": "cents",
- "unit_amount": 0,
- "total": 0,
- "charged": true
}, - "test": true,
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "final_at": "2019-08-24T14:15:22Z",
- "campaign_id": "78614b6c-fe7c-41e2-8e25-c9b3a3c91904",
- "held_until": "2019-08-24T14:15:22Z",
- "attempts": [
- {
- "number": 0,
- "state": "submitting",
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z"
}
], - "receipts": [
- {
- "delivery": "delivered",
- "raw_status": "string",
- "failure_reason": "string",
- "effect": "delivered",
- "received_at": "2019-08-24T14:15:22Z"
}
]
}
}Single messages and campaign messages together, unless campaign narrows the list. A campaign message's
to is masked (+25575****01) unless the caller is a member who may read contacts (contacts.read);
API keys always see campaign numbers masked.
| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
| campaign | string^(none|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA...
|
{- "success": true,
- "data": {
- "messages": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "accepted",
- "to": "string",
- "from": "string",
- "body": "string",
- "traffic_class": "transactional",
- "client_ref": "string",
- "encoding": "gsm7",
- "segments": 1,
- "price": {
- "currency": "TZS",
- "unit": "cents",
- "unit_amount": 0,
- "total": 0,
- "charged": true
}, - "test": true,
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "final_at": "2019-08-24T14:15:22Z",
- "campaign_id": "78614b6c-fe7c-41e2-8e25-c9b3a3c91904",
- "held_until": "2019-08-24T14:15:22Z",
- "attempts": [
- {
- "number": 0,
- "state": "submitting",
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z"
}
], - "receipts": [
- {
- "delivery": "delivered",
- "raw_status": "string",
- "failure_reason": "string",
- "effect": "delivered",
- "received_at": "2019-08-24T14:15:22Z"
}
]
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}| id required | string |
{- "success": true,
- "data": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "accepted",
- "to": "string",
- "from": "string",
- "body": "string",
- "traffic_class": "transactional",
- "client_ref": "string",
- "encoding": "gsm7",
- "segments": 1,
- "price": {
- "currency": "TZS",
- "unit": "cents",
- "unit_amount": 0,
- "total": 0,
- "charged": true
}, - "test": true,
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "final_at": "2019-08-24T14:15:22Z",
- "campaign_id": "78614b6c-fe7c-41e2-8e25-c9b3a3c91904",
- "held_until": "2019-08-24T14:15:22Z",
- "attempts": [
- {
- "number": 0,
- "state": "submitting",
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z"
}
], - "receipts": [
- {
- "delivery": "delivered",
- "raw_status": "string",
- "failure_reason": "string",
- "effect": "delivered",
- "received_at": "2019-08-24T14:15:22Z"
}
]
}
}| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
{- "success": true,
- "data": {
- "campaigns": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}A draft holds the name, traffic class, sender ID, one body for every recipient (no personalization) and
the contact groups. Nothing is sent, priced or held: quote it next (POST /v1/campaigns/{id}/quote).
Launching (POST /v1/campaigns/{id}/launch) needs a signed-in member.
Idempotency. X-Idempotency-Key is required: the same key with the same draft returns the original
(200, header X-Idempotent-Replay: true), so a double click creates one draft; the same key with a
different draft is 409 idempotency_conflict.
Test-mode keys cannot create campaigns (403 test_mode_not_allowed). Session callers also send
X-CSRF-Token. At most 100 drafts (409 too_many_drafts; discard one) and 50 groups per campaign. The body is
checked as for single messages (at most 10 segments: 422 too_many_segments). Refusals:
422 validation_failed (with field), 403 forbidden.
| X-Idempotency-Key required | string [ 1 .. 255 ] characters ^[\x21-\x7E]+$ Client-generated unique key (for example a UUID), 1-255 printable ASCII characters, not starting with
|
| name required | string [ 1 .. 100 ] characters |
| traffic_class | string Default: "transactional" Enum: "transactional" "promotional" |
| sender_id required | string One of the organization's sender IDs (checked against its approval when quoted). |
| body required | string [ 1 .. 1600 ] characters The same text for every recipient (at most 10 segments). |
| group_ids | Array of strings <uuid> <= 50 items [ items <uuid > ] Default: [] |
| scheduled_at | string or null <date-time> Up to 30 days ahead; null starts at launch. The launch names the schedule it confirms (the dashboard proposes this one). |
| validity_seconds | integer or null [ 3600 .. 259200 ] How long after the start unsent messages stay sendable (1 to 72 hours); null for the default. |
{- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "group_ids": [ ],
- "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 3600
}{- "success": true,
- "data": {
- "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
}
}| id required | string |
{- "success": true,
- "data": {
- "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
}
}Only a draft can change (409 campaign_not_draft). Send only the fields to change; scheduled_at and
validity_seconds can be cleared with null. Every change bumps version and drops the draft's quote
and audience snapshot: quote again before launching. Send version to refuse a change to a draft that
changed since you loaded it (409 campaign_changed); without it, the change applies to the draft as it is now. Test-mode keys are refused
(403 test_mode_not_allowed); session callers also send X-CSRF-Token.
| id required | string |
| version | integer >= 1 The version you loaded; the change is refused if the draft has moved on. |
| name | string [ 1 .. 100 ] characters |
| traffic_class | string Enum: "transactional" "promotional" |
| sender_id | string |
| body | string [ 1 .. 1600 ] characters |
| group_ids | Array of strings <uuid> <= 50 items [ items <uuid > ] |
| scheduled_at | string or null <date-time> |
| validity_seconds | integer or null [ 3600 .. 259200 ] |
{- "version": 1,
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 3600
}{- "success": true,
- "data": {
- "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
}
}Every recipient of the campaign's frozen audience, in audience order (n), with its message once one was
queued. status is the message's public status once the recipient was fed (accepted, submitted,
delivered, undelivered, failed, unknown, cancelled); before that, the recipient's own: pending
(its share still held), skipped_opted_out or skipped_no_consent (skipped when it was due, share
released), expired (the campaign expired before it was sent) or cancelled (cancelled before it was
sent). submitted is the provider's acceptance, not delivery. charge is where the recipient's share of
the reservation is now: held, captured (the provider accepted the message) or released (back in
the wallet).
Numbers are shown in full only to members who may read contacts (contacts.read); API keys and other
callers see them masked (+25575****01, numbers_masked: true) and cannot search (403 forbidden,
field search). search is the start of a number (0754, +255 754, 754). A draft has no recipients
to show (409 campaign_not_launched). A read that takes too long answers 503 read_timeout: retry, or
narrow it.
| id required | string |
| status | string (CampaignRecipientStatusFilter) Enum: "pending" "accepted" "submitted" "delivered" "undelivered" "failed" "unknown" "cancelled" "expired" "skipped_opted_out" "skipped_no_consent" "skipped" One status, or |
| search | string <= 20 characters The start of a Tanzanian mobile number (needs contacts.read). |
| after | integer >= 0 Keyset cursor, the |
| limit | integer [ 1 .. 200 ] Default: 50 |
{- "success": true,
- "data": {
- "recipients": [
- {
- "n": 1,
- "to": "string",
- "status": "pending",
- "message_id": "d7d9d9fd-478f-40e6-b651-49b7f19878a2",
- "failure_reason": "string",
- "segments": 1,
- "unit_amount": 0,
- "amount": 0,
- "charge": "held",
- "queued_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "final_at": "2019-08-24T14:15:22Z"
}
], - "next_after": 0,
- "numbers_masked": true
}
}The recipients of GET /v1/campaigns/{id}/messages as one CSV file (UTF-8, comma-separated, with a header
line), in audience order: recipient (n), phone, status, failure_reason, segments,
unit_price_cents, amount_cents (the recipient's share), charge
(held, captured or released), queued_at_eat, updated_at_eat, final_at_eat (Africa/Dar_es_Salaam,
with the +03:00 offset) and message_id. Numbers are in full only for members who may read contacts,
masked otherwise, as in the list.
A value whose first character, after any leading spaces, control or format characters, is =, +, -,
@, |, a tab, a carriage return, a line feed or a full-width = + - @ gets a leading apostrophe, so a
spreadsheet never runs it as a formula (every phone number starts with '+255). The file is read from the
database a page at a time while it is sent; if a read fails midway, the connection is broken rather than
the file ending short. At most 500,000 rows per file (422 export_too_large: export one status at a
time); exports are limited per organization (429 rate_limited) and each one is audited.
| id required | string |
| status | string (CampaignRecipientStatusFilter) Enum: "pending" "accepted" "submitted" "delivered" "undelivered" "failed" "unknown" "cancelled" "expired" "skipped_opted_out" "skipped_no_consent" "skipped" Export only this status ( |
{- "success": false,
- "error": {
- "code": "invalid_request",
- "message": "X-Idempotency-Key header is required"
}
}In one step, the database freezes the audience and prices it:
audience.duplicates);audience.opted_out), whatever the class;audience.no_consent);total = unit_amount × segments × audience.eligible (cents).The consent and opt-out rules are provisional product defaults. The quote is immutable and
valid for 15 minutes; a later rate change never alters it. Quoting again rebuilds the audience; editing
the draft drops it. Nothing is held from the wallet until launch. assumptions lists
the caveats that apply to every quote, as codes.
Idempotency. X-Idempotency-Key is required: the same key for the same campaign returns the original
quote (200, X-Idempotent-Replay: true), whose status may since have become stale (the draft changed,
lost a group, or was quoted again) or expired; the same key for another campaign is 409.
Refusals: 409 campaign_not_draft, 409 campaign_changed, 422 audience_empty (nobody eligible, or no
groups), 422 audience_too_large (over 1,000,000 numbers or 2,000,000 group memberships), 422 total_too_large,
403 sender_not_authorized (field sender_id: the sender is not approved for the class), 409 no_price_plan,
422 too_many_segments, 422 encoding_not_supported (the body has characters the SMS route cannot send:
see POST /v1/messages), 403 test_mode_not_allowed, 503 quote_timeout. A quote prices the campaign on
the SMS route it was made on; once the route changes, it is stale. Deleting one of the draft's groups
takes it out of the draft and drops its quote (the draft's version moves on).
Session callers also send X-CSRF-Token.
| id required | string |
| X-Idempotency-Key required | string [ 1 .. 255 ] characters ^[\x21-\x7E]+$ Client-generated unique key (for example a UUID), 1-255 printable ASCII characters, not starting with
|
| version | integer >= 1 The draft version you reviewed; refused with |
{- "version": 1
}{- "success": true,
- "data": {
- "quote": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "current",
- "campaign_version": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "audience": {
- "eligible": 1,
- "duplicates": 0,
- "opted_out": 0,
- "no_consent": 0
}, - "encoding": "gsm7",
- "segments": 1,
- "traffic_class": "transactional",
- "unit_amount": 0,
- "total": 0,
- "currency": "TZS",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "assumptions": [
- "segment_sizes_standard"
]
}, - "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
}
}The draft becomes discarded: it is kept for the record, its audience is deleted, and it no longer counts
toward the 100-draft limit. Discarding a discarded campaign returns it unchanged (header
X-Idempotent-Replay: true). A launched campaign is 409 campaign_not_draft (cancel it instead). Test-mode keys are refused; session callers also send X-CSRF-Token.
| id required | string |
{- "success": true,
- "data": {
- "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
}
}Launching holds the quote's total from the wallet in one step (a single reservation for the whole
campaign) and the campaign becomes scheduled (with scheduled_at) or sending. Name the quote you
reviewed: it must be the draft's latest quote, for the draft as it is now (409 quote_stale otherwise:
the draft changed, lost a group, or was quoted again, or the SMS route changed since) and unexpired
(409 quote_expired: quote again). A body the SMS route cannot send is 422 encoding_not_supported.
The quote is honoured: a rate change after it does not change the price. Each message then draws its
share (unit_amount × segments): charged when the provider accepts it, released when it fails,
expires or is cancelled. Recipients who opt out (or, for promotional campaigns, lose their consent
record) after the quote are skipped when their turn comes, and their share is released
(provisional consent rules).
Refusals change nothing and keep the draft: 422 insufficient_funds (add credit and launch again),
403 wallet_frozen, 403 organization_not_verified, 403 sender_not_authorized,
422 sender_not_ready (field sender_id: the SMS route has not approved the sender ID yet),
422 schedule_out_of_range (start now or within 30 days; validity 1 to 72 hours),
422 outside_sending_window (a promotional campaign's start, now or scheduled, must fall inside the
sending window: GET /v1/sending-window), 409 campaign_not_draft (it was launched or discarded already).
Sending window (promotional only; provisional): a promotional campaign is fed
and sent only inside the window. When the window closes, its unsent messages are held, unsent and
uncharged, until it opens again; what is still unsent at the campaign's expiry (validity_seconds
after the start) expires and its share is released. Transactional campaigns have no window.
Idempotency. X-Idempotency-Key is required: the same key with the same request returns the original
(200, X-Idempotent-Replay: true); with another request, 409 idempotency_conflict. A campaign is
launched once, whatever the key. API keys cannot launch (until spending caps exist). Session
callers send X-CSRF-Token.
| id required | string |
| X-Idempotency-Key required | string [ 1 .. 255 ] characters ^[\x21-\x7E]+$ Client-generated unique key (for example a UUID), 1-255 printable ASCII characters, not starting with
|
| quote_id required | string <uuid> The quote the member reviewed and confirmed. |
| scheduled_at | string or null <date-time> Start later (up to 30 days ahead); null or absent starts now. |
| validity_seconds | integer or null [ 3600 .. 259200 ] How long after the start unsent messages stay sendable; null for the draft's, else 24 hours. |
{- "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 3600
}{- "success": true,
- "data": {
- "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}
}
}Stops a scheduled or sending campaign: it becomes cancelling, the share of every recipient not yet
queued is released at once, and its queued messages are cancelled (status cancelled, their share
released) within moments; then it is cancelled. Messages already with the provider (sending, submitted,
unknown) are not recalled: a send that began a moment before the cancel is sent and charged. cancel
reports what the cancel found. Cancelling again returns the original (X-Idempotent-Replay: true); no
idempotency key is needed. A draft, discarded or completed campaign is 409 campaign_not_cancellable.
Session callers send X-CSRF-Token.
| id required | string |
{- "success": true,
- "data": {
- "campaign": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "traffic_class": "transactional",
- "sender_id": "string",
- "body": "string",
- "encoding": "gsm7",
- "segments": 0,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "scheduled_at": "2019-08-24T14:15:22Z",
- "validity_seconds": 0,
- "status": "draft",
- "version": 1,
- "created_via": "dashboard",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "quote": { },
- "launch": { }
}, - "cancel": {
- "pending_cancelled": 0,
- "queued_at_cancel": 0,
- "in_flight_at_cancel": 0
}
}
}Promotional traffic is dispatched only between opens and closes each day, in time_zone
(Africa/Dar_es_Salaam, EAT, UTC+3). A promotional campaign must start inside the window
(422 outside_sending_window at launch), and promotional messages, from campaigns or single sends, are
held while it is closed (never sent, never charged), then sent when it opens, unless they expire first.
Transactional traffic has no window. The window is one platform setting: a provisional product default
(08:00 to 20:00), not a statement of law (provisional).
{- "success": true,
- "data": {
- "sending_window": {
- "applies_to": "promotional",
- "opens": "string",
- "closes": "string",
- "time_zone": "Africa/Dar_es_Salaam",
- "open_now": true,
- "next_opening": "2019-08-24T14:15:22Z"
}
}
}Contacts, groups, consent and opt-outs (session only). The consent and opt-out rules are provisional product defaults, not legal advice: promotional messages go only to contacts with a consent record and no active opt-out; an opt-out never blocks a transactional message.
Session-only; an API key cannot reach contacts. Filter by the start of a number or by group.
| search | string <= 20 characters The start of a Tanzanian mobile number, as typed ( |
| group_id | string <uuid> Only members of this group. |
| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
{- "success": true,
- "data": {
- "contacts": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "name": "string",
- "consent": { },
- "opted_out": true,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}The number is normalized to +255…; one contact per number (409 contact_exists, field phone).
consent records that the person agreed to receive messages and how (the note); promotional messages
go only to contacts with a consent record and no active opt-out. A consent record is never overwritten.
The consent rules are provisional product defaults, not legal advice.
| X-CSRF-Token required | string Value of the |
| phone required | string A Tanzanian mobile number: +255…, 255… or 0…. |
| name | string or null <= 100 characters |
object (ConsentInput) Records that the person agreed to receive messages. Wording is provisional. | |
| group_ids | Array of strings <uuid> <= 500 items [ items <uuid > ] |
{- "phone": "string",
- "name": "string",
- "consent": {
- "note": "string"
}, - "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
]
}{- "success": true,
- "data": {
- "contact": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "name": "string",
- "consent": { },
- "opted_out": true,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}| id required | string |
{- "success": true,
- "data": {
- "contact": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "name": "string",
- "consent": { },
- "opted_out": true,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}name: a string sets it, null or an empty string clears it, absent leaves it. consent records consent
when none is recorded; an existing record is kept as it is. The number never changes: delete the contact
and add the new number.
| id required | string |
| X-CSRF-Token required | string Value of the |
| name | string or null <= 100 characters |
object (ConsentInput) Records that the person agreed to receive messages. Wording is provisional. |
{- "name": "string",
- "consent": {
- "note": "string"
}
}{- "success": true,
- "data": {
- "contact": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "name": "string",
- "consent": { },
- "opted_out": true,
- "group_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}The consent history and any opt-out of the number are kept; they belong to the number.
| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}{- "success": true,
- "data": {
- "groups": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "members": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}
}Names are unique per organization, ignoring case (409 group_name_taken); at most 500 groups (409 too_many_groups).
| X-CSRF-Token required | string Value of the |
| name required | string [ 1 .. 100 ] characters |
{- "name": "string"
}{- "success": true,
- "data": {
- "group": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "members": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}| id required | string |
| X-CSRF-Token required | string Value of the |
| name required | string [ 1 .. 100 ] characters |
{- "name": "string"
}{- "success": true,
- "data": {
- "group": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "members": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}Contacts already in the group are left as they are. Every id must be one of the organization's contacts.
| id required | string |
| X-CSRF-Token required | string Value of the |
| contact_ids required | Array of strings <uuid> [ 1 .. 500 ] items [ items <uuid > ] |
{- "contact_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
]
}{- "success": true,
- "data": {
- "added": 0
}
}Contacts not in the group are ignored.
| id required | string |
| X-CSRF-Token required | string Value of the |
| contact_ids required | Array of strings <uuid> [ 1 .. 500 ] items [ items <uuid > ] |
{- "contact_ids": [
- "497f6eca-6276-4993-bfeb-53cbbbba6f08"
]
}{- "success": true,
- "data": {
- "removed": 0
}
}| status | string Default: "active" Enum: "active" "cleared" "all" |
| search | string <= 20 characters The start of a Tanzanian mobile number, as typed. |
| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
{- "success": true,
- "data": {
- "opt_outs": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "source": "manual",
- "reason": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "cleared_at": "2019-08-24T14:15:22Z",
- "clear_reason": "string",
- "clear_evidence": "string"
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}An active opt-out blocks every promotional message to the number (422 recipient_opted_out on
POST /v1/messages), never a transactional one. It is kept per number, so it outlives the contact.
Recording a number that is already opted out returns the active opt-out with 200 and created: false.
Sendd adds no opt-out wording to messages and has no inbound STOP keyword yet (provisional).
| X-CSRF-Token required | string Value of the |
| phone required | string A Tanzanian mobile number: +255…, 255… or 0…. |
| reason | string <= 500 characters How the person asked. Optional. |
{- "phone": "string",
- "reason": "string"
}{- "success": true,
- "data": {
- "opt_out": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "source": "manual",
- "reason": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "cleared_at": "2019-08-24T14:15:22Z",
- "clear_reason": "string",
- "clear_evidence": "string"
}, - "created": true
}
}For a person who asked to receive messages again. The reason and the evidence (3 to 500 characters each)
are kept in the consent history and the audit log; the opt-out itself is never deleted. A cleared
opt-out cannot be cleared again (409 already_cleared). Addressed by id, so no phone number appears
in a URL or a log.
| id required | string |
| X-CSRF-Token required | string Value of the |
| reason required | string [ 3 .. 500 ] characters |
| evidence required | string [ 3 .. 500 ] characters What shows the person agreed again. |
{- "reason": "string",
- "evidence": "string"
}{- "success": true,
- "data": {
- "opt_out": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "phone": "string",
- "source": "manual",
- "reason": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "cleared_at": "2019-08-24T14:15:22Z",
- "clear_reason": "string",
- "clear_evidence": "string"
}
}
}| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
{- "success": true,
- "data": {
- "imports": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "state": "uploading",
- "filename": "string",
- "delimiter": "comma",
- "columns": [
- "string"
], - "rows": 0,
- "bytes": 0,
- "error": { },
- "mapping": { },
- "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
- "consent": { },
- "counts": {
- "processed": 0,
- "created": 0,
- "existing": 0,
- "duplicate": 0,
- "invalid": 0,
- "opted_out": 0,
- "consent_recorded": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "uploaded_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "purged_at": "2019-08-24T14:15:22Z"
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}The body is the file itself, streamed (Content-Type: text/csv; a byte-order mark is accepted).
The first line is the header; the delimiter (comma, semicolon or tab) is detected from it. Limits: 25 MiB,
200,000 rows after the header, 50 columns, 500 characters per value. A file over a limit fails with
413 import_too_large; a file that is not UTF-8, has broken quoting or a NUL character fails with
422 invalid_csv (the message names the line). Rows with the wrong number of values are kept and
reported as invalid when the import is processed.
Nothing is added to the address book yet: the import is uploaded, ready for its column mapping
(POST /v1/contact-imports/{id}/process). One import at a time per organization (409 import_in_progress)
and 20 a day (429 import_limit_reached; an upload refused before any row was read does not count); at
most 1,000,000 uploaded rows are kept per organization at a time (429 import_limit_reached). Too many
uploads in progress on the server at once: 503 busy, with Retry-After. An upload that is interrupted
fails, and its rows are deleted. An upload not processed within 24 hours expires. Once a row is processed
only its phone cell (at most 32 characters) is kept, for the error report, until 30 days after the upload;
then the rows, the header and the file name are deleted. The counts stay.
| filename | string <= 200 characters The file's name, shown in the import list. |
| X-CSRF-Token required | string Value of the |
The CSV file, at most 25 MiB.
{- "success": true,
- "data": {
- "import": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "state": "uploading",
- "filename": "string",
- "delimiter": "comma",
- "columns": [
- "string"
], - "rows": 0,
- "bytes": 0,
- "error": { },
- "mapping": { },
- "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
- "consent": { },
- "counts": {
- "processed": 0,
- "created": 0,
- "existing": 0,
- "duplicate": 0,
- "invalid": 0,
- "opted_out": 0,
- "consent_recorded": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "uploaded_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "purged_at": "2019-08-24T14:15:22Z"
}
}
}Poll it while the import is queued or processing to follow the counts.
| id required | string |
{- "success": true,
- "data": {
- "import": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "state": "uploading",
- "filename": "string",
- "delimiter": "comma",
- "columns": [
- "string"
], - "rows": 0,
- "bytes": 0,
- "error": { },
- "mapping": { },
- "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
- "consent": { },
- "counts": {
- "processed": 0,
- "created": 0,
- "existing": 0,
- "duplicate": 0,
- "invalid": 0,
- "opted_out": 0,
- "consent_recorded": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "uploaded_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "purged_at": "2019-08-24T14:15:22Z"
}, - "sample": [
- [
- "string"
]
]
}
}Only an uploaded import (409 import_not_ready otherwise), and one at a time per organization
(409 import_in_progress). The import becomes queued, then processing; follow it with
GET /v1/contact-imports/{id}.
Each row's number is normalized to +255…; a row with an invalid number or value is invalid, and a
number seen earlier in the file is a duplicate (the first row wins). Existing contacts are kept: a
missing name is filled in, a recorded consent is never overwritten and an opt-out is never cleared.
A row whose opted_out value is yes (yes, y, true, 1, x) records an opt-out for its number,
duplicates included, and rows invalid for another reason than their number (an opt-out is never lost).
consent attests that the people in the file agreed to receive messages, and how (the source).
With it, a consent record is kept for each contact that has none and whose number has no active opt-out
(opt-outs from the file are recorded first);
when a consent_date column is mapped, only rows with a date get one, dated then. Without consent,
no consent is recorded and consent_date may not be mapped. The wording is provisional, not legal
advice.
| id required | string |
| X-CSRF-Token required | string Value of the |
required | object (ContactImportMapping) Column positions in the header, from 0. Each column may be used once. |
| group_id | string or null <uuid> Add every valid contact to this group. |
| group_name | string or null [ 1 .. 100 ] characters Create a group with this name and add every valid contact to it. Not with |
object or null |
{- "mapping": {
- "phone": 0,
- "name": 0,
- "consent_date": 0,
- "opted_out": 0
}, - "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
- "group_name": "string",
- "consent": {
- "attested": true,
- "source": "string"
}
}{- "success": true,
- "data": {
- "import": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "state": "uploading",
- "filename": "string",
- "delimiter": "comma",
- "columns": [
- "string"
], - "rows": 0,
- "bytes": 0,
- "error": { },
- "mapping": { },
- "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
- "consent": { },
- "counts": {
- "processed": 0,
- "created": 0,
- "existing": 0,
- "duplicate": 0,
- "invalid": 0,
- "opted_out": 0,
- "consent_recorded": 0
}, - "created_at": "2019-08-24T14:15:22Z",
- "uploaded_at": "2019-08-24T14:15:22Z",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "purged_at": "2019-08-24T14:15:22Z"
}
}
}A bulk export of phone numbers, so it needs contacts.manage. Invalid and duplicate rows of a completed
import (409 import_not_ready before), in file order:
row (1 is the first row after the header), line (the line in the file), phone (the cell as it
was, at most its first 32 characters), reason_code and reason. A value whose first character, after
any leading spaces, control or format characters, is =, +, -, @, |, a tab, a carriage return,
a line feed or a full-width = + - @ gets a leading apostrophe, so a spreadsheet never runs it as a
formula. Gone once the rows are deleted (410 import_purged); if the deletion begins while the report
is being sent, the connection is broken rather than the file ending short. Each download is audited.
| id required | string |
{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}A message can be sent only as a sender ID assigned to the organization, approved, permitting the
message's traffic class, and not rejected by the provider. Sendd staff assign sender IDs; provider_status is the provider's own approval
as Sendd last recorded it, tracked separately. Approved first, at most 200.
{- "success": true,
- "data": {
- "sender_ids": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sender_id": "string",
- "status": "requested",
- "permitted_classes": [
- "transactional"
], - "provider_status": "unknown",
- "updated_at": "2019-08-24T14:15:22Z",
- "provider_approval": {
- "status": "unknown",
- "disabled": true,
- "ready": true,
- "synced_at": "2019-08-24T14:15:22Z"
}
}
]
}
}{- "success": true,
- "data": {
- "sender_id_requests": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sender_id": "string",
- "permitted_classes": [
- "transactional"
], - "use_case": "string",
- "sample_message": "string",
- "evidence": "string",
- "status": "requested",
- "reason": "string",
- "sender_uuid": "6fd60e87-ecde-4069-8fdb-aebbacafea6d",
- "approved_sender_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "decided_at": "2019-08-24T14:15:22Z"
}
]
}
}A Sendd admin reviews the request and approves or rejects it with a reason. Approval assigns the sender
to the organization; the provider's own approval is tracked separately (provisional) and shown as
provider_status on GET /v1/sender-ids. At most 5 requests may be open (409 sender_request_limit),
and one per sender text (409 sender_request_exists); a sender whose use by the organization is suspended
or revoked cannot be asked for again (409 sender_blocked), nor can a shared platform brand
(409 sender_shared). Once approved, send as approved_sender_id exactly. Evidence is a text note for now (file uploads
come later). The acceptable-use terms for sender IDs are not written yet (provisional).
| X-CSRF-Token required | string Value of the |
| sender_id required | string [ 2 .. 11 ] characters 2 to 11 letters, digits, single spaces or . & -, with at least one letter, starting and ending with a letter or digit. |
| permitted_classes required | Array of strings [ 1 .. 2 ] items unique Items Enum: "transactional" "promotional" |
| use_case required | string [ 10 .. 500 ] characters What the organization will send as this sender. |
| sample_message required | string [ 1 .. 500 ] characters |
| evidence required | string [ 10 .. 1000 ] characters Why the organization may use the name (for example its registration or trademark), as text. |
{- "sender_id": "string",
- "permitted_classes": [
- "transactional"
], - "use_case": "stringstri",
- "sample_message": "string",
- "evidence": "stringstri"
}{- "success": true,
- "data": {
- "sender_id_request": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sender_id": "string",
- "permitted_classes": [
- "transactional"
], - "use_case": "string",
- "sample_message": "string",
- "evidence": "string",
- "status": "requested",
- "reason": "string",
- "sender_uuid": "6fd60e87-ecde-4069-8fdb-aebbacafea6d",
- "approved_sender_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "decided_at": "2019-08-24T14:15:22Z"
}
}
}409 request_not_open once it was approved, rejected or withdrawn.
| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": true,
- "data": {
- "sender_id_request": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "sender_id": "string",
- "permitted_classes": [
- "transactional"
], - "use_case": "string",
- "sample_message": "string",
- "evidence": "string",
- "status": "requested",
- "reason": "string",
- "sender_uuid": "6fd60e87-ecde-4069-8fdb-aebbacafea6d",
- "approved_sender_id": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "decided_at": "2019-08-24T14:15:22Z"
}
}
}available is spendable; reserved is held for queued sends. Top-ups still waiting for payment are
counted separately and are never spendable. Amounts are cents (1/100 TZS).
{- "success": true,
- "data": {
- "wallet": {
- "currency": "TZS",
- "unit": "cents",
- "available": 0,
- "reserved": 0,
- "frozen": true,
- "pending_top_ups": {
- "count": 0,
- "amount_tzs": 0
}
}
}
}One line per immutable ledger posting, with its effect on the available and reserved balances in cents.
Kinds: topup, reserve, capture (a charge), release (held funds returned) and adjustment (a
manual correction approved by two Sendd staff). Fees and VAT are not shown yet.
Pass next_before as before for the next page; it is null on the last page.
| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
{- "success": true,
- "data": {
- "currency": "TZS",
- "unit": "cents",
- "entries": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "kind": "topup",
- "reference": "string",
- "available_change": 0,
- "reserved_change": 0,
- "created_at": "2019-08-24T14:15:22Z"
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}Rates and quotes in cents (1/100 TZS), computed in the database from the organization's effective plan.
409 no_price_plan until Sendd has set a rate card that applies to the organization.
{- "success": true,
- "data": {
- "plan_name": "string",
- "currency": "TZS",
- "unit": "cents_per_segment",
- "rates": [
- {
- "encoding": "gsm7",
- "traffic_class": "transactional",
- "unit_amount": 1
}
]
}
}Permission campaigns.execute. The price comes from the organization's effective plan, computed in
the database: total = unit_amount × segments × recipients (cents). Segments are supplied by the caller
until the segment calculator arrives (Phase 3). Valid for 15 minutes by default, 60 seconds to 24 hours.
A quote reserves nothing; it is safe to repeat.
| X-CSRF-Token required | string Value of the |
| encoding required | string Enum: "gsm7" "ucs2" |
| traffic_class required | string Enum: "transactional" "promotional" |
| segments required | integer [ 1 .. 10 ] |
| recipients required | integer [ 1 .. 1000000 ] |
| valid_for_seconds | integer [ 60 .. 86400 ] Default: 900 Omit for the default. |
{- "encoding": "gsm7",
- "traffic_class": "transactional",
- "segments": 1,
- "recipients": 1,
- "valid_for_seconds": 900
}{- "success": true,
- "data": {
- "quote": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "encoding": "gsm7",
- "traffic_class": "transactional",
- "segments": 0,
- "recipients": 0,
- "unit_amount": 0,
- "total": 0,
- "currency": "TZS",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "expired": true
}
}
}| id required | string |
{- "success": true,
- "data": {
- "quote": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "encoding": "gsm7",
- "traffic_class": "transactional",
- "segments": 0,
- "recipients": 0,
- "unit_amount": 0,
- "total": 0,
- "currency": "TZS",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "expired": true
}
}
}{- "success": true,
- "data": {
- "top_ups": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "amount_tzs": 0,
- "currency": "TZS",
- "status": "created",
- "phone_masked": "string",
- "prompt_sent": true,
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
], - "limits": {
- "min_tzs": 0,
- "max_tzs": 0
}
}
}Owner only (billing.manage), for an organization Sendd has verified (403 organization_not_verified).
Sendd creates an order with its payment processor and sends a payment prompt to the phone. The top-up stays pending until
the processor confirms the payment; the wallet is credited only then, once, and only for exactly the amount
asked (whole shillings, within limits). A pending top-up is never spendable.
X-Idempotency-Key is required: the same key with the same request returns the original top-up
(200, no second prompt); with a different request it is 409 idempotency_conflict. At most five
top-ups per organization can be in progress (409 too_many_top_ups). 503 payments_unavailable
when payments are off.
Statuses: pending (waiting for payment), completed (credited), expired (no payment yet after
30 minutes), failed and cancelled (what the payment processor reported), needs_review (Sendd staff check it, for
example when the processor reports a different amount). Sendd keeps checking expired, failed and
cancelled top-ups for 7 days and credits them if the payment arrives after all. 429 when too many
prompts were sent recently (per organization and per phone number).
| X-CSRF-Token required | string Value of the |
| X-Idempotency-Key required | string [ 8 .. 200 ] characters ^[\x21-\x7E]+$ |
| amount_tzs required | integer >= 1 Whole shillings, within limits. |
| phone required | string Tanzanian mobile number: +255…, 255…, 0… or the 9-digit national number. |
{- "amount_tzs": 1,
- "phone": "string"
}{- "success": true,
- "data": {
- "top_up": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "amount_tzs": 0,
- "currency": "TZS",
- "status": "created",
- "phone_masked": "string",
- "prompt_sent": true,
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
}
}| id required | string |
{- "success": true,
- "data": {
- "top_up": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "amount_tzs": 0,
- "currency": "TZS",
- "status": "created",
- "phone_masked": "string",
- "prompt_sent": true,
- "failure_reason": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
}
}Includes revoked and expired keys. Never includes secrets. Session-only; an API key cannot list keys.
{- "success": true,
- "data": {
- "api_keys": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "prefix": "string",
- "mode": "test",
- "scopes": [
- "messages:send"
], - "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "last_used_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "rotated_from": "c5500ae4-3bf4-4b9d-9292-5dcc0d464d8b",
- "status": "active"
}
]
}
}Session-only (permission api_keys.manage: owner and developer); an API key cannot mint keys.
test keys never reach a real provider or the wallet, and any key manager may give them any scope.
live keys need an organization Sendd has verified (403 organization_not_verified), and each scope
must be something the creator's role may do itself (403 scope_exceeds_role; in practice owners make
live keys). At most 20 active keys (409 too_many_keys). Every key expires: 365 days by default,
at most 730. The secret is the whole key and is never shown again.
| X-CSRF-Token required | string Value of the |
| name required | string [ 1 .. 100 ] characters |
| mode required | string Enum: "test" "live" |
| scopes required | Array of strings (ApiKeyScope) non-empty Items Enum: "messages:send" "messages:read" "campaigns:read" "campaigns:write" "sender_ids:read" "wallet:read" "webhooks:read" |
| expires_in_days | integer [ 1 .. 730 ] Default: 365 |
{- "name": "string",
- "mode": "test",
- "scopes": [
- "messages:send"
], - "expires_in_days": 365
}{- "success": true,
- "data": {
- "api_key": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "prefix": "string",
- "mode": "test",
- "scopes": [
- "messages:send"
], - "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "last_used_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "rotated_from": "c5500ae4-3bf4-4b9d-9292-5dcc0d464d8b",
- "status": "active"
}, - "secret": "string"
}
}| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}Creates a key with the same name, mode and scopes (rotated_from points at the old one) and makes
the old key expire after grace_hours (default 24, 0 = immediately, at most 168), never later than
it would have. Rotation does not need a free slot under the 20-key cap. A key can be rotated once
(409 already_rotated); rotate its replacement next. The body may be empty.
| id required | string |
| X-CSRF-Token required | string Value of the |
| grace_hours | integer [ 0 .. 168 ] Default: 24 |
{- "grace_hours": 24
}{- "success": true,
- "data": {
- "api_key": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "prefix": "string",
- "mode": "test",
- "scopes": [
- "messages:send"
], - "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "last_used_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "rotated_from": "c5500ae4-3bf4-4b9d-9292-5dcc0d464d8b",
- "status": "active"
}, - "secret": "string"
}
}Any valid key works; no scope needed. Useful to test an integration's credentials. After 50 invalid
keys from one network (IPv6 /64; 500 per /48) in 10 minutes, Bearer requests from it get 429 before
any lookup, except with a key that authenticated recently.
{- "success": true,
- "data": {
- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0",
- "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
- "prefix": "string",
- "mode": "test",
- "scopes": [
- "messages:send"
]
}
}Never includes signing secrets. Session-only; an API key cannot list or change endpoints.
{- "success": true,
- "data": {
- "webhook_endpoints": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "url": "string",
- "description": "string",
- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "enabled": true,
- "disabled_reason": "string",
- "secret_prefix": "string",
- "previous_secret_expires_at": "2019-08-24T14:15:22Z",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}
}Session-only (permission webhooks.manage: owner and developer). An API key can never add or change
where events go: a leaked key that could would receive every later event, recipient numbers included.
The URL must be https (plain http only on a local development server), with no credentials and
no fragment, at most 2,048 characters, on port 443 or 1024-65535. A host that is an IP address in any
spelling (decimal, octal, hex, IPv4-mapped IPv6) must be public. Single-label names (intranet),
internal suffixes (loopback names, .local, .internal, .lan, .home.arpa, .localdomain, .svc,
.cluster.local, .corp, .intranet) and Sendd's own and its partners' hosts are refused
(422 webhook_url_blocked); any other bad shape is 422 webhook_url_invalid. Names are resolved only
when Sendd connects, and the address actually dialled is checked then, so a name that points at a
private address is refused on every delivery. Sendd never follows redirects. The URL never changes:
to move an endpoint, add a new one (a new secret) and delete the old one.
The secret (whsec_ + 43 characters) signs every delivery: Sendd-Signature: t=<unix>,v1=<hex>,
an HMAC-SHA256 of <t>.<raw body>. It is shown only in this response and stored encrypted.
At most 10 endpoints per organization (409 webhook_limit_reached); the same URL and mode once
(409 webhook_endpoint_exists). An endpoint gets events of its own mode only.
| X-CSRF-Token required | string Value of the |
| url required | string <= 2048 characters |
| mode required | string Enum: "test" "live" |
| event_types required | Array of strings (WebhookEventType) non-empty Items Enum: "message.status" "campaign.scheduled" "campaign.sending" "campaign.completed" "campaign.cancelled" "topup.completed" |
| status_filter | Array of strings or null (WebhookMessageStatus) Enum: "accepted" "submitted" "delivered" "undelivered" "failed" "unknown" "cancelled" For |
| description | string <= 200 characters |
{- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "description": "string"
}{- "success": true,
- "data": {
- "webhook_endpoint": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "url": "string",
- "description": "string",
- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "enabled": true,
- "disabled_reason": "string",
- "secret_prefix": "string",
- "previous_secret_expires_at": "2019-08-24T14:15:22Z",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "secret": "string"
}
}| id required | string |
{- "success": true,
- "data": {
- "webhook_endpoint": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "url": "string",
- "description": "string",
- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "enabled": true,
- "disabled_reason": "string",
- "secret_prefix": "string",
- "previous_secret_expires_at": "2019-08-24T14:15:22Z",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}Fields left out are unchanged. The URL never changes (422 webhook_url_immutable): a secret stays
with the receiver it was issued for, so to move an endpoint add a new one and delete the old one.
| id required | string |
| X-CSRF-Token required | string Value of the |
| mode | string Enum: "test" "live" |
| event_types | Array of strings (WebhookEventType) non-empty Items Enum: "message.status" "campaign.scheduled" "campaign.sending" "campaign.completed" "campaign.cancelled" "topup.completed" |
| status_filter | Array of strings or null (WebhookMessageStatus) Enum: "accepted" "submitted" "delivered" "undelivered" "failed" "unknown" "cancelled" |
| description | string <= 200 characters |
| enabled | boolean |
{- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "description": "string",
- "enabled": true
}{- "success": true,
- "data": {
- "webhook_endpoint": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "url": "string",
- "description": "string",
- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "enabled": true,
- "disabled_reason": "string",
- "secret_prefix": "string",
- "previous_secret_expires_at": "2019-08-24T14:15:22Z",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}
}| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}The new secret is shown only in this response. For 24 hours (previous_secret_expires_at) every
delivery carries two v1= signatures, one per secret, so receivers can switch without downtime;
after that only the new secret signs. Another rotation is refused until the window has closed
(409 rotation_in_progress), so a secret receivers may still rely on is never ended early.
| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": true,
- "data": {
- "webhook_endpoint": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "url": "string",
- "description": "string",
- "mode": "test",
- "event_types": [
- "message.status"
], - "status_filter": [
- "accepted"
], - "enabled": true,
- "disabled_reason": "string",
- "secret_prefix": "string",
- "previous_secret_expires_at": "2019-08-24T14:15:22Z",
- "last_success_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "secret": "string"
}
}Delivered inline, signed like any event, through the same guarded client as real deliveries: no
proxy, no redirects (a 3xx counts as failed), a private or reserved address refused at connect
time, 10 seconds in total. Only an audit row is kept. Any 2xx is succeeded. The excerpt is at
most 512 bytes of the response body, with control characters replaced. A refused address, a failed
lookup and a refused connection are all unreachable, and latency is a coarse bucket. At most 10
tests per member and 20 per organization in 10 minutes, and 2 at a time per organization
(429 rate_limited).
| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": true,
- "data": {
- "test": {
- "event_id": "a7a26ff2-e851-45b6-9634-d595f45458b7",
- "outcome": "succeeded",
- "http_status": 0,
- "latency_bucket": "lt_250ms",
- "error_class": "unreachable",
- "response_excerpt": "string",
- "signatures": 1
}
}
}One delivery per event and endpoint. An event is written, in the same transaction as the change it
reports, only while an enabled endpoint of its mode subscribes to its type. Delivery is
at-least-once and order is not guaranteed: de-duplicate on the event id (Sendd-Event-Id), and
order by the event's created_at and status.
Retries. Any 2xx succeeds. Anything else (a timeout, a 3xx, a refused connection) is retried
about 1m, 5m, 30m, 2h, 6h, 12h, 24h and 24h later (each ±20%), with the same event id; after the 9th
automatic attempt the delivery is exhausted. An endpoint with no success for 5 days is disabled
(disabled_reason: auto_failing), and deliveries waiting for a disabled endpoint are cancelled.
Events, deliveries and attempts are kept 30 days (provisional).
| endpoint_id | string <uuid> |
| type | string (WebhookEventType) Enum: "message.status" "campaign.scheduled" "campaign.sending" "campaign.completed" "campaign.cancelled" "topup.completed" |
| state | string (WebhookDeliveryState) Enum: "pending" "delivering" "succeeded" "exhausted" "cancelled" |
| event_id | string <uuid> |
| before | string <uuid> Keyset cursor, the |
| limit | integer [ 1 .. 100 ] Default: 50 |
{- "success": true,
- "data": {
- "webhook_deliveries": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "event_id": "a7a26ff2-e851-45b6-9634-d595f45458b7",
- "event_type": "message.status",
- "endpoint_id": "e9ce0d4f-d433-423d-9497-4c000544106c",
- "mode": "test",
- "state": "pending",
- "attempts": 0,
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "redelivery_queued": true,
- "last_attempt_at": "2019-08-24T14:15:22Z",
- "last_http_status": 0,
- "last_error_class": "unreachable",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "next_before": "12f84c9b-c9d0-42a8-9698-9c41ba4b7fa0"
}
}The event is the exact envelope sent (data is a snapshot taken when the change committed; it never
holds a message body). Attempts show a latency bucket, never an exact timing; a refused address, a
failed lookup and a refused connection are all unreachable. An abandoned attempt was cut off
before its result was recorded: the receiver may have it, and the next attempt carries the same event id.
An API key without messages:read sees the event with data.to set to null.
| id required | string |
{- "success": true,
- "data": {
- "webhook_delivery": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "event_id": "a7a26ff2-e851-45b6-9634-d595f45458b7",
- "event_type": "message.status",
- "endpoint_id": "e9ce0d4f-d433-423d-9497-4c000544106c",
- "mode": "test",
- "state": "pending",
- "attempts": 0,
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "redelivery_queued": true,
- "last_attempt_at": "2019-08-24T14:15:22Z",
- "last_http_status": 0,
- "last_error_class": "unreachable",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "endpoint_host": "string",
- "event": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "type": "message.status",
- "created_at": "2019-08-24T14:15:22Z",
- "mode": "test",
- "data": { }
}, - "attempt_log": [
- {
- "number": 1,
- "manual": true,
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z",
- "outcome": "succeeded",
- "http_status": 0,
- "latency_bucket": "lt_250ms",
- "error_class": "unreachable",
- "response_excerpt": "string",
- "signatures": 1
}
]
}
}
}Audited. The attempt is made by the worker within seconds and listed with manual: true; it does not
count toward the 9 automatic attempts. If it fails, a delivery that had finished returns to its
earlier state, and a pending one continues its schedule. Refused while an attempt is in flight or a
redelivery is already queued (409 delivery_in_progress) and while the endpoint is disabled or deleted
(409 endpoint_disabled), and for an event within a day of its 30-day purge or a delivery with
1,000 attempts (409 redelivery_closed). At most 60 an hour per organization (429 rate_limited).
| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": true,
- "data": {
- "webhook_delivery": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "event_id": "a7a26ff2-e851-45b6-9634-d595f45458b7",
- "event_type": "message.status",
- "endpoint_id": "e9ce0d4f-d433-423d-9497-4c000544106c",
- "mode": "test",
- "state": "pending",
- "attempts": 0,
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "redelivery_queued": true,
- "last_attempt_at": "2019-08-24T14:15:22Z",
- "last_http_status": 0,
- "last_error_class": "unreachable",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "endpoint_host": "string",
- "event": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "type": "message.status",
- "created_at": "2019-08-24T14:15:22Z",
- "mode": "test",
- "data": { }
}, - "attempt_log": [
- {
- "number": 1,
- "manual": true,
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z",
- "outcome": "succeeded",
- "http_status": 0,
- "latency_bucket": "lt_250ms",
- "error_class": "unreachable",
- "response_excerpt": "string",
- "signatures": 1
}
]
}
}
}Always returns 202 with the same body, whether or not the address already has an account
(an existing owner is emailed instead), so the endpoint cannot be used to discover accounts.
Signing up again on an unverified address changes nothing; the inbox is sent a single-use link to
choose a password (POST /v1/auth/reset-password), so only the inbox owner can claim it.
Requires an allowed Origin header (dashboard only).
| email required | string <email> <= 254 characters |
| password required | string [ 12 .. 128 ] characters |
| full_name | string <= 100 characters |
{- "email": "user@example.com",
- "password": "stringstring",
- "full_name": "string"
}{- "success": true,
- "data": {
- "message": "string"
}
}Requires the account's password, so only the person who chose it and controls the inbox can verify (prevents pre-registering someone else's address). A wrong password leaves the link usable. Verification revokes every session opened before it.
| token required | string |
| password required | string |
{- "token": "string",
- "password": "string"
}{- "success": true,
- "data": {
- "message": "string"
}
}The link proves control of the inbox, so it also verifies the email. Used to claim a pending (unverified) registration after signing up again with the same address. Revokes every session and every other outstanding link for the user.
| token required | string |
| password required | string [ 12 .. 128 ] characters |
{- "token": "string",
- "password": "stringstring"
}{- "success": true,
- "data": {
- "message": "string"
}
}Always 202; reveals nothing about the address.
| email required | string |
{- "email": "string"
}{- "success": true,
- "data": {
- "message": "string"
}
}Sets an httpOnly session cookie (__Host-sendd_session; sendd_session in local) and a readable
sendd_csrf cookie whose value must be sent as X-CSRF-Token on unsafe session requests.
Unknown email and wrong password return the same 401. Any existing session is revoked.
| email required | string |
| password required | string |
{- "email": "string",
- "password": "string"
}{- "success": true,
- "data": {
- "user": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "email": "string",
- "full_name": "string",
- "email_verified": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "active_tenant_id": "b0929d8e-b244-4f34-959c-7c2501fcf908",
- "platform_admin": true,
- "mfa": {
- "enrolled": true,
- "verified_at": "2019-08-24T14:15:22Z"
}, - "memberships": [
- {
- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0",
- "legal_name": "string",
- "tenant_status": "string",
- "role": "owner"
}
]
}
}| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}{- "success": true,
- "data": {
- "user": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "email": "string",
- "full_name": "string",
- "email_verified": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "active_tenant_id": "b0929d8e-b244-4f34-959c-7c2501fcf908",
- "platform_admin": true,
- "mfa": {
- "enrolled": true,
- "verified_at": "2019-08-24T14:15:22Z"
}, - "memberships": [
- {
- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0",
- "legal_name": "string",
- "tenant_status": "string",
- "role": "owner"
}
]
}
}Tenants, members and invitations (session-based; each operation's x-sendd-permission names the role permission it needs).
Requires a verified email. Makes the new organization the session's active one. Sending is blocked until an admin verifies it (can_send false).
| X-CSRF-Token required | string Value of the |
| legal_name required | string [ 2 .. 200 ] characters |
| business_category | string <= 100 characters |
{- "legal_name": "string",
- "business_category": "string"
}{- "success": true,
- "data": {
- "tenant": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "legal_name": "string",
- "business_category": "string",
- "country": "string",
- "status": "pending_verification",
- "status_reason": "string",
- "can_send": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "role": "owner",
- "permissions": [
- "string"
]
}
}Returns 404 for organizations the user is not a member of (no leakage of whether they exist).
| X-CSRF-Token required | string Value of the |
| tenant_id required | string <uuid> |
{- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0"
}{- "success": false,
- "error": {
- "code": "unauthorized",
- "message": "Invalid API key"
}
}Every organization route re-checks membership; a removed member gets 403 not_a_member and the session's
active organization is cleared. With no active organization, 409 no_active_organization. A suspended or
rejected organization is read-only: unsafe requests get 403 organization_restricted.
{- "success": true,
- "data": {
- "tenant": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "legal_name": "string",
- "business_category": "string",
- "country": "string",
- "status": "pending_verification",
- "status_reason": "string",
- "can_send": true,
- "created_at": "2019-08-24T14:15:22Z"
}, - "role": "owner",
- "permissions": [
- "string"
]
}
}The dashboard's first screen. Every member may read it, and each section appears only for roles holding
its permission; a section the role may not see is null: wallet needs billing.read, messages
reports.read and recent_campaigns campaigns.read. messages counts live messages (never test mode) by
public status, created in the last 24 hours and the last 7 days. alerts lists what the organization
should act on, most urgent first, limited to what the role may see (figures may be up to 10 seconds old): organization_restricted,
wallet_frozen (danger), organization_unverified, no_approved_sender (sender_ids.use) and
low_balance (billing.read; below wallet.low_balance_threshold, a platform setting), failing_webhook
(webhooks.manage; an endpoint failing now, or switched off by Sendd for failing) and sending_delayed (every
member; Sendd has paused dispatch, see GET /v1/service-status). At most 60
reads a minute per organization (429 rate_limited). Session only.
{- "success": true,
- "data": {
- "overview": {
- "generated_at": "2019-08-24T14:15:22Z",
- "wallet": {
- "available": 0,
- "reserved": 0,
- "frozen": true,
- "low_balance_threshold": 0,
- "unit": "cents"
}, - "messages": {
- "last_24h": {
- "accepted": 0,
- "submitted": 0,
- "delivered": 0,
- "undelivered": 0,
- "failed": 0,
- "unknown": 0,
- "cancelled": 0
}, - "last_7d": {
- "accepted": 0,
- "submitted": 0,
- "delivered": 0,
- "undelivered": 0,
- "failed": 0,
- "unknown": 0,
- "cancelled": 0
}
}, - "recent_campaigns": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "status": "draft",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "eligible_count": 0,
- "created_at": "2019-08-24T14:15:22Z"
}
], - "alerts": [
- {
- "code": "organization_restricted",
- "severity": "danger"
}
]
}
}
}sending_delayed is true while Sendd's operators have paused dispatch for the whole platform or for any
provider (an incident, a provider outage). Nothing is dropped:
queued messages wait, and are sent once dispatch resumes, unless their validity runs out first (they then
expire and their money is released, without waiting for the resume). webhooks_delayed is the same for webhook deliveries. No reason or
other detail is given. The same condition raises the sending_delayed overview alert. Session only.
{- "success": true,
- "data": {
- "service_status": {
- "sending_delayed": true,
- "webhooks_delayed": true,
- "sms_route": "gsm0338"
}
}
}{- "success": true,
- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
- "email": "string",
- "full_name": "string",
- "role": "owner",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}Only owners grant or remove the owner role; the last owner cannot be demoted (409). Another organization's member IDs return 404.
| id required | string |
| X-CSRF-Token required | string Value of the |
| role required | string (Role) Enum: "owner" "admin" "operator" "viewer" "developer" |
{- "role": "owner"
}{- "success": false,
- "error": {
- "code": "forbidden",
- "message": "Not permitted"
}
}The last owner cannot be removed (409). Takes effect on the member's next request.
| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "forbidden",
- "message": "Not permitted"
}
}{- "success": true,
- "data": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "email": "string",
- "role": "owner",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]
}The invitation link is emailed and never returned. A newer invitation to the same address replaces older
ones; at most one is pending per address (a concurrent duplicate gets 409). Owners cannot be invited directly.
Unverified organizations may send only 5 invitations a day. The email contains no organization or inviter
names (they are user-chosen); the invitee sees them via POST /v1/invitations/preview after signing in.
If the email cannot be sent, the invitation is cancelled.
| X-CSRF-Token required | string Value of the |
| email required | string |
| role required | string Enum: "admin" "operator" "viewer" "developer" |
{- "email": "string",
- "role": "admin"
}{- "success": true,
- "data": {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "email": "string",
- "role": "owner",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
}| id required | string |
| X-CSRF-Token required | string Value of the |
{- "success": false,
- "error": {
- "code": "forbidden",
- "message": "Not permitted"
}
}Same checks as accept (valid, unexpired, and for the signed-in user's verified email), with no changes. Organization and inviter names are user-chosen, so they appear only here and never in the invitation email.
| X-CSRF-Token required | string Value of the |
| token required | string |
{- "token": "string"
}{- "success": true,
- "data": {
- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0",
- "tenant_name": "string",
- "tenant_verified": true,
- "role": "owner",
- "inviter_name": "string",
- "inviter_email": "string",
- "expires_at": "2019-08-24T14:15:22Z"
}
}The signed-in user's verified email must match the invited address. Makes the organization active.
| X-CSRF-Token required | string Value of the |
| token required | string |
{- "token": "string"
}{- "success": true,
- "data": {
- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0"
}
}