Sendd API (0.1.0)

Download OpenAPI specification:

License: LicenseRef-Proprietary

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.

Messages

Send a single SMS (permission campaigns.execute, or an API key with messages:send)

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).

Authorizations:
ApiKeyBearerSessionCookie
header Parameters
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 campaign: (reserved). Scoped to tenant + operation + request fingerprint. Same key and same body replays the original response; same key and different body returns 409.

Request Body schema: application/json
required
to
required
string

A Tanzanian mobile number. +255… (E.164) is preferred; 255… and 0… are normalized.

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).

Responses

Request samples

Content type
application/json
{
  • "to": "+255712345678",
  • "from": "SENDD",
  • "body": "Your order #1042 is ready for pickup.",
  • "client_ref": "order-1042-ready",
  • "ttl": 300
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The organization's messages, newest first (permission reports.read, or an API key with messages:read)

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.

Authorizations:
ApiKeyBearerSessionCookie
query Parameters
before
string <uuid>

Keyset cursor, the next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50
campaign
string^(none|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA...

none lists single messages only; a campaign id lists that campaign's messages.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Get a message and its current status (permission reports.read, or an API key with messages:read)

Authorizations:
ApiKeyBearerSessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Campaigns

The organization's campaigns, newest first (permission campaigns.read, or an API key with campaigns:read)

Authorizations:
SessionCookieApiKeyBearer
query Parameters
before
string <uuid>

Keyset cursor, the next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Create a draft campaign (permission campaigns.manage, or a live API key with campaigns:write)

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.

Authorizations:
SessionCookieApiKeyBearer
header Parameters
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 campaign: (reserved). Scoped to tenant + operation + request fingerprint. Same key and same body replays the original response; same key and different body returns 409.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "traffic_class": "transactional",
  • "sender_id": "string",
  • "body": "string",
  • "group_ids": [ ],
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "validity_seconds": 3600
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Get a campaign and its latest quote (permission campaigns.read, or an API key with campaigns:read)

Authorizations:
SessionCookieApiKeyBearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Change a draft (permission campaigns.manage, or a live API key with campaigns:write)

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.

Authorizations:
SessionCookieApiKeyBearer
path Parameters
id
required
string
Request Body schema: application/json
required
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 ]

Responses

Request samples

Content type
application/json
{
  • "version": 1,
  • "name": "string",
  • "traffic_class": "transactional",
  • "sender_id": "string",
  • "body": "string",
  • "group_ids": [
    ],
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "validity_seconds": 3600
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

A launched campaign's recipients and their messages (permission campaigns.read, or an API key with campaigns:read)

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.

Authorizations:
SessionCookieApiKeyBearer
path Parameters
id
required
string
query Parameters
status
string (CampaignRecipientStatusFilter)
Enum: "pending" "accepted" "submitted" "delivered" "undelivered" "failed" "unknown" "cancelled" "expired" "skipped_opted_out" "skipped_no_consent" "skipped"

One status, or skipped for both skipped statuses.

search
string <= 20 characters

The start of a Tanzanian mobile number (needs contacts.read).

after
integer >= 0

Keyset cursor, the next_after of the previous page.

limit
integer [ 1 .. 200 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

A launched campaign's recipients as CSV (permission campaigns.read, or an API key with campaigns:read)

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.

Authorizations:
SessionCookieApiKeyBearer
path Parameters
id
required
string
query Parameters
status
string (CampaignRecipientStatusFilter)
Enum: "pending" "accepted" "submitted" "delivered" "undelivered" "failed" "unknown" "cancelled" "expired" "skipped_opted_out" "skipped_no_consent" "skipped"

Export only this status (skipped for both skipped statuses).

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Quote a draft (permission campaigns.manage, or a live API key with campaigns:write)

In one step, the database freezes the audience and prices it:

  • one recipient per distinct number of the draft's groups (a contact in several groups counts once: audience.duplicates);
  • numbers with an active opt-out are skipped (audience.opted_out), whatever the class;
  • for promotional campaigns, contacts without a consent record are skipped (audience.no_consent);
  • segments are counted from the body, and the price comes from the organization's current rates: 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.

Authorizations:
SessionCookieApiKeyBearer
path Parameters
id
required
string
header Parameters
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 campaign: (reserved). Scoped to tenant + operation + request fingerprint. Same key and same body replays the original response; same key and different body returns 409.

Request Body schema: application/json
optional
version
integer >= 1

The draft version you reviewed; refused with 409 campaign_changed if the draft has moved on. Part of the request fingerprint.

Responses

Request samples

Content type
application/json
{
  • "version": 1
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Discard a draft for good (permission campaigns.manage, or a live API key with campaigns:write)

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.

Authorizations:
SessionCookieApiKeyBearer
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Launch a quoted draft (permission campaigns.execute; signed-in members only)

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
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 campaign: (reserved). Scoped to tenant + operation + request fingerprint. Same key and same body replays the original response; same key and different body returns 409.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "quote_id": "3c071a1d-db86-46a7-9dc8-72ba3fbca992",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "validity_seconds": 3600
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Cancel what is unsent of a campaign (permission campaigns.execute; signed-in members only)

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.

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The promotional sending window (permission campaigns.read, or an API key with campaigns:read)

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).

Authorizations:
SessionCookieApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Contacts

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.

The organization's contacts, newest first (permission contacts.read)

Session-only; an API key cannot reach contacts. Filter by the start of a number or by group.

Authorizations:
SessionCookie
query Parameters
search
string <= 20 characters

The start of a Tanzanian mobile number, as typed (0754, +255 75, 75).

group_id
string <uuid>

Only members of this group.

before
string <uuid>

Keyset cursor, the next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Add a contact (permission contacts.manage)

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.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
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 > ]

Responses

Request samples

Content type
application/json
{
  • "phone": "string",
  • "name": "string",
  • "consent": {
    },
  • "group_ids": [
    ]
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

One contact (permission contacts.read)

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Rename a contact or record its consent (permission contacts.manage)

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
name
string or null <= 100 characters
object (ConsentInput)

Records that the person agreed to receive messages. Wording is provisional.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "consent": {
    }
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Delete a contact and its group memberships (permission contacts.manage)

The consent history and any opt-out of the number are kept; they belong to the number.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

The organization's groups, by name (permission contacts.read)

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Create a group (permission contacts.manage)

Names are unique per organization, ignoring case (409 group_name_taken); at most 500 groups (409 too_many_groups).

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
name
required
string [ 1 .. 100 ] characters

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Rename a group (permission contacts.manage)

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
name
required
string [ 1 .. 100 ] characters

Responses

Request samples

Content type
application/json
{
  • "name": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Delete a group; its contacts stay (permission contacts.manage)

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Add contacts to a group (permission contacts.manage)

Contacts already in the group are left as they are. Every id must be one of the organization's contacts.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
contact_ids
required
Array of strings <uuid> [ 1 .. 500 ] items [ items <uuid > ]

Responses

Request samples

Content type
application/json
{
  • "contact_ids": [
    ]
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Take contacts out of a group (permission contacts.manage)

Contacts not in the group are ignored.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
contact_ids
required
Array of strings <uuid> [ 1 .. 500 ] items [ items <uuid > ]

Responses

Request samples

Content type
application/json
{
  • "contact_ids": [
    ]
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Numbers that asked not to be messaged, newest first (permission contacts.read)

Authorizations:
SessionCookie
query Parameters
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 next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Record that a number asked not to be messaged (permission contacts.manage)

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).

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
phone
required
string

A Tanzanian mobile number: +255…, 255… or 0….

reason
string <= 500 characters

How the person asked. Optional.

Responses

Request samples

Content type
application/json
{
  • "phone": "string",
  • "reason": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Clear an opt-out, once, with a reason and evidence (permission contacts.manage)

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
reason
required
string [ 3 .. 500 ] characters
evidence
required
string [ 3 .. 500 ] characters

What shows the person agreed again.

Responses

Request samples

Content type
application/json
{
  • "reason": "string",
  • "evidence": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The organization's contact imports, newest first (permission contacts.read)

Authorizations:
SessionCookie
query Parameters
before
string <uuid>

Keyset cursor, the next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Upload a CSV file of contacts (permission contacts.manage)

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.

Authorizations:
SessionCookie
query Parameters
filename
string <= 200 characters

The file's name, shown in the import list.

header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: text/csv
required
string

The CSV file, at most 25 MiB.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

One import, with its first five rows for members who may import (permission contacts.read)

Poll it while the import is queued or processing to follow the counts.

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Map the columns and add the rows to the address book (permission contacts.manage)

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
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 group_id.

object or null

Responses

Request samples

Content type
application/json
{
  • "mapping": {
    },
  • "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f",
  • "group_name": "string",
  • "consent": {
    }
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The rows that were not imported, as CSV (permission contacts.manage)

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.

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Sender IDs

The organization's sender IDs and their status (permission sender_ids.use, or an API key with sender_ids:read)

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.

Authorizations:
SessionCookieApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The organization's sender ID requests, newest first, at most 100 (permission sender_ids.use)

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Ask Sendd for a sender ID (permission sender_ids.request; session only)

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).

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "sender_id": "string",
  • "permitted_classes": [
    ],
  • "use_case": "stringstri",
  • "sample_message": "string",
  • "evidence": "stringstri"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Withdraw an open sender ID request (permission sender_ids.request; session only)

409 request_not_open once it was approved, rejected or withdrawn.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Wallet

The organization's balance in cents (permission billing.read, or an API key with wallet:read)

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).

Authorizations:
SessionCookieApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The organization's statement, newest first (permission billing.read, or an API key with wallet:read)

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.

Authorizations:
SessionCookieApiKeyBearer
query Parameters
before
string <uuid>

Keyset cursor, the next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Pricing

Rates and quotes in cents (1/100 TZS), computed in the database from the organization's effective plan.

The organization's current per-segment rates (cents)

409 no_price_plan until Sendd has set a rate card that applies to the organization.

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Price a message (stores an immutable, expiring snapshot)

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.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "encoding": "gsm7",
  • "traffic_class": "transactional",
  • "segments": 1,
  • "recipients": 1,
  • "valid_for_seconds": 900
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

One of the organization's quotes

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Payments

The organization's 50 most recent top-ups, newest first (permission billing.read)

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Add credit by mobile money (a USSD payment prompt to the phone)

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).

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

X-Idempotency-Key
required
string [ 8 .. 200 ] characters ^[\x21-\x7E]+$
Request Body schema: application/json
required
amount_tzs
required
integer >= 1

Whole shillings, within limits.

phone
required
string

Tanzanian mobile number: +255…, 255…, 0… or the 9-digit national number.

Responses

Request samples

Content type
application/json
{
  • "amount_tzs": 1,
  • "phone": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

One of the organization's top-ups (permission billing.read)

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

API keys

The organization's API keys, newest first (permission api_keys.manage)

Includes revoked and expired keys. Never includes secrets. Session-only; an API key cannot list keys.

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Create a scoped API key (secret shown once)

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.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
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

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "mode": "test",
  • "scopes": [
    ],
  • "expires_in_days": 365
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Revoke an API key (takes effect immediately)

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Replace a key, keeping the old one working for a grace period

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
optional
grace_hours
integer [ 0 .. 168 ]
Default: 24

Responses

Request samples

Content type
application/json
{
  • "grace_hours": 24
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Check an API key (which organization, mode and scopes)

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.

Authorizations:
ApiKeyBearer

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Webhooks

The organization's webhook endpoints (permission webhooks.manage)

Never includes signing secrets. Session-only; an API key cannot list or change endpoints.

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Register a webhook endpoint (signing secret shown once)

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.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
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 message.status, only these statuses (null or absent means all).

description
string <= 200 characters

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

One webhook endpoint (never its secret)

Authorizations:
SessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Change an endpoint's mode, events or description, or switch it on or off

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
non-empty
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

Responses

Request samples

Content type
application/json
{
  • "mode": "test",
  • "event_types": [
    ],
  • "status_filter": [
    ],
  • "description": "string",
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Delete an endpoint (its secret is destroyed at once)

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Issue a new signing secret; the old one keeps working for 24 hours

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.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Send a signed webhook.test event to the endpoint now and report the attempt

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).

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The organization's webhook deliveries, newest first (permission webhooks.manage, or an API key with webhooks:read)

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).

Authorizations:
ApiKeyBearerSessionCookie
query Parameters
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 next_before of the previous page.

limit
integer [ 1 .. 100 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

A webhook delivery with its event and every attempt (permission webhooks.manage, or an API key with webhooks:read)

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.

Authorizations:
ApiKeyBearerSessionCookie
path Parameters
id
required
string

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Queue one more attempt of a delivery now, with the same event (permission webhooks.manage, session only)

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).

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Health

Liveness probe

Responses

Response samples

Content type
application/json
{
  • "status": "ok"
}

Readiness probe (pings the database)

Ready when the database is reachable. Provider health is reported but does not make the API unready, because accepted work is queued durably.

Responses

Response samples

Content type
application/json
{
  • "status": "ok",
  • "provider": "ok"
}

Auth

Dashboard sign-up and sessions (cookie-based; not for API keys).

Create an account and send a verification email

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).

Request Body schema: application/json
required
email
required
string <email> <= 254 characters
password
required
string [ 12 .. 128 ] characters
full_name
string <= 100 characters

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "password": "stringstring",
  • "full_name": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Consume a single-use email verification token

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.

Request Body schema: application/json
required
token
required
string
password
required
string

Responses

Request samples

Content type
application/json
{
  • "token": "string",
  • "password": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Set a password from an emailed single-use link

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.

Request Body schema: application/json
required
token
required
string
password
required
string [ 12 .. 128 ] characters

Responses

Request samples

Content type
application/json
{
  • "token": "string",
  • "password": "stringstring"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Email a new verification link if the address needs one

Always 202; reveals nothing about the address.

Request Body schema: application/json
required
email
required
string

Responses

Request samples

Content type
application/json
{
  • "email": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Sign in and start a browser session

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.

Request Body schema: application/json
required
email
required
string
password
required
string

Responses

Request samples

Content type
application/json
{
  • "email": "string",
  • "password": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Revoke the current session

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

The signed-in user

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Organizations

Tenants, members and invitations (session-based; each operation's x-sendd-permission names the role permission it needs).

Create an organization (caller becomes owner; starts pending verification)

Requires a verified email. Makes the new organization the session's active one. Sending is blocked until an admin verifies it (can_send false).

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
legal_name
required
string [ 2 .. 200 ] characters
business_category
string <= 100 characters

Responses

Request samples

Content type
application/json
{
  • "legal_name": "string",
  • "business_category": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Switch the session to another organization the user belongs to

Returns 404 for organizations the user is not a member of (no leakage of whether they exist).

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
tenant_id
required
string <uuid>

Responses

Request samples

Content type
application/json
{
  • "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0"
}

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

The active organization, the caller's role and permissions

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.

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

The active organization's overview (every member; sections by permission)

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.

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Whether Sendd is holding sending or webhook deliveries right now (every member)

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.

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Active members (permission members.read)

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": [
    ]
}

Change a member's role (permission members.manage)

Only owners grant or remove the owner role; the last owner cannot be demoted (409). Another organization's member IDs return 404.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
role
required
string (Role)
Enum: "owner" "admin" "operator" "viewer" "developer"

Responses

Request samples

Content type
application/json
{
  • "role": "owner"
}

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Remove a member (permission members.manage)

The last owner cannot be removed (409). Takes effect on the member's next request.

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

Pending invitations (permission members.read)

Authorizations:
SessionCookie

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "data": [
    ]
}

Invite someone by email (permission members.manage)

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.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
email
required
string
role
required
string
Enum: "admin" "operator" "viewer" "developer"

Responses

Request samples

Content type
application/json
{
  • "email": "string",
  • "role": "admin"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Revoke a pending invitation (permission members.manage)

Authorizations:
SessionCookie
path Parameters
id
required
string
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Responses

Response samples

Content type
application/json
{
  • "success": false,
  • "error": {
    }
}

See who invited you, to which organization and as what role, before accepting

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.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
token
required
string

Responses

Request samples

Content type
application/json
{
  • "token": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}

Join an organization from an invitation link

The signed-in user's verified email must match the invited address. Makes the organization active.

Authorizations:
SessionCookie
header Parameters
X-CSRF-Token
required
string

Value of the sendd_csrf cookie. Required on unsafe session-authenticated requests.

Request Body schema: application/json
required
token
required
string

Responses

Request samples

Content type
application/json
{
  • "token": "string"
}

Response samples

Content type
application/json
{
  • "success": true,
  • "data": {
    }
}