InfiSend
Jump to a section

Developers

API reference

Everything you need to send SMS and email through InfiSend: authentication, request and response shapes for every endpoint, the delivery webhook contract, errors and rate limits.

Base URL
https://infisend-api.infi-tech.cloud
Version
v1
Spec
OpenAPI 3

Getting started

Introduction

One REST API for SMS through Africa’s Talking and email over SMTP, billed per message from a prepaid MWK wallet. Same authentication, same response shape and same webhook contract on both channels — a channel is one path segment.

What to know before the first call

PropertyBehaviour
TransportHTTPS only. JSON request and response bodies throughout.
Sending is asynchronousA send returns 202 Accepted once the message row is written and its cost debited. The outcome arrives on your webhook — there is deliberately no polling endpoint.
Money is a stringAmounts are decimal strings in MWK ("15.00"), never JSON numbers. A float in a balance is not recoverable after the fact, so the wire format refuses to carry one.
TimestampsISO 8601 with a timezone, always UTC — 2026-08-14T09:41:22.104Z.
TracingEvery response carries x-correlation-id. Send your own and it is used instead and threaded through our logs — quote it in a support request and the whole send is recoverable.
Two surfaces/v1/* is the machine surface and takes an API key. Everything that administers the account — wallet, keys, templates, webhooks, logs — takes a dashboard session token.
Base URL
all endpoints
https://infisend-api.infi-tech.cloud
Conventions
every request
Authorization: Bearer sk_live_…
Content-Type: application/json
x-correlation-id: <optional, echoed back>

Getting started

Quickstart

Sign up, generate a sandbox key from the dashboard, and make one call that costs nothing and consumes no message. If this returns your account, the credential and the transport are both correct and everything below is a body change.
  1. Create an account. Your account and its wallet exist immediately — no KYC, no top-up.
  2. Generate a sandbox key on the API keys screen and pick its scopes. The secret is shown exactly once.
  3. Call GET /v1/me with it (right) to confirm it works.
  4. Build the whole integration against sandbox, then submit KYC, fund the wallet and swap in a live key. Nothing else about the request changes.

Keep the secret server-side

An API key can spend your wallet. Never ship one in a mobile app, a browser bundle or a public repository — if one leaks, revoke it from the dashboard and rotate. Rotation mints a replacement and leaves the original active until you delete it, so there is no gap in service.
Request
GET /v1/me
curl https://infisend-api.infi-tech.cloud/v1/me \
  -H "Authorization: Bearer $INFISEND_API_KEY"
Response · 200
application/json
{
  "apiKeyId": "key_01HZX8Q2M4N6P8",
  "accountId": "acc_01HZX7B5C9D2E4",
  "name": "Checkout service",
  "environment": "SANDBOX",
  "scopes": ["SMS", "EMAIL"],
  "keyPrefix": "sk_test_a1b2c3d4"
}

Core concepts

Authentication

Both credentials are presented the same way — as a bearer token in the Authorization header — but they are not interchangeable, and which one an endpoint takes is part of its contract.
CredentialLooks likeCan doCannot do
API keysk_live_… / sk_test_…Send, schedule and cancel on the channels its scopes allow.Touch the wallet, mint keys, edit templates or repoint a webhook.
Session tokenJWT from /auth/loginAdminister the account: wallet, keys, KYC, templates, webhooks, logs.Be handed to a server-side integration as a send credential.

The split is deliberate: a leaked send credential must not be able to mint itself a live key or repoint your webhook at somebody else’s server.

Key scopes

SMSscopeoptional
Permits POST /v1/sms/send and /v1/sms/bulk. A key without it gets a 403 on those routes.
EMAILscopeoptional
The same for /v1/email/send and /v1/email/bulk.
READ_ONLYscopeoptional
Read access without send rights. Scopes are a grant list rather than a mode, so ["SMS", "READ_ONLY"] is a valid and useful combination.

Keys are stored hashed

The secret is shown once at creation and never again — Infitech stores only a peppered HMAC-SHA-256 digest and the sk_test_a1b2c3d4-style display prefix. Lose it and the fix is a rotation, not a support ticket.
API key
the /v1/* surface
Authorization: Bearer sk_live_9f3c…
# sandbox keys are prefixed sk_test_
Session token
account management
POST /auth/login
{ "email": "you@company.mw", "password": "…" }

→ Authorization: Bearer <jwt>

Core concepts

Environments

Every key is either SANDBOX or LIVE, and the key alone decides which one a send runs in. There is no environment field on a /v1 request body: the sandbox is a property of the credential, not an option on the call.
SandboxLive
Key prefixsk_test_sk_live_
AvailableThe moment the account existsAfter KYC approval and a funded wallet
WalletPriced, never chargedDebited on accept, refunded or corrected on outcome
ProviderSimulator with realistic delays and failuresAfrica's Talking / SMTP
Status transitionsIdenticalIdentical
WebhooksIdentical payloads and signaturesIdentical payloads and signatures

Sandbox mirrors live on purpose. Anything you can observe from your side — statuses, timings, webhook bodies, failure codes — behaves the same, so an integration that works against a test key works against a live one without a code change.

Same request, either environment
only the key changes
# sandbox — free, simulated, never reaches a provider
export INFISEND_API_KEY=sk_test_…

# live — charged, real handset, real inbox
export INFISEND_API_KEY=sk_live_…

Core concepts

Errors

Conventional HTTP status codes. 4xx means something about the request or the account needs to change and retrying it unchanged will fail the same way; 5xx is ours and is safe to retry with the same idempotency key.

A schema rejection carries an issues array naming the field and the rule that failed. Submitted values are never echoed back, so a rejected payload cannot leak into your logs through ours.

Status codes

CodeMeaningWhat to do
400Malformed body, invalid or repeated recipient, several email To addresses without mode SHARED, or template variables not satisfied.Fix the request. Retrying it unchanged will not help.
401Missing, unknown or revoked API key.Check the header format and that the key has not been revoked.
402The wallet cannot cover this send.Top up. Nothing was created and nothing was charged.
403The key lacks the scope this channel requires, the account is not eligible to send live, or a recipient has opted out.Issue a key with the right scope, finish KYC, or remove the suppressed recipients the body lists.
404No such message, template or endpoint on this account.Check the id. Cross-account reads return 404, not 403.
409IdempotencyKeyConflict: the key was already used for a different request. Or, on a cancel: the scheduled message was already released, or was never scheduled.Use a new key for a new request. On a cancel, you lost the race against the sweep — the send is going out.
413An email names more recipients (To + Cc + Bcc) than one message may carry.Split the recipients across several emails, or send each a separate email with bulk.
422No price is configured for the route, or the From address is on a domain not verified for your account.Ask Infitech to price the route, or verify the domain. Nothing was charged.
429Per-key rate limit exceeded.Back off for Retry-After seconds.
5xxOur fault.Retry with the same idempotency key — it cannot double-send.

A failed send is not a charge

The cost is debited when a send is accepted and returned as an explicit REFUND line if the provider rejects it or delivery fails. You are never left paying for a message that did not go out — see Money & pricing.
Error · 402
application/json
{
  "statusCode": 402,
  "message": "Insufficient wallet balance: 12.00 available, 15.00 required.",
  "error": "Payment Required"
}
Validation error · 400
application/json
{
  "message": "Validation failed.",
  "issues": [
    { "path": "to", "message": "Phone number must be in E.164 format, e.g. +265991234567." },
    { "path": "message", "message": "Provide either the message content or `templateId`, not both." }
  ]
}

Core concepts

Rate limits

The /v1/* surface is limited per API key — not per IP — so several services behind one NAT do not share a budget, and one service spread across changing addresses does not escape the limit.

The shipped default is 120 requests per 60 seconds per key. Read the headers rather than hard-coding it: a limit can be raised for an account, and a client that respects X-RateLimit-Remaining keeps working when it is.

Bulk is one request, not N

A recipient list sent through /v1/sms/bulk costs one request against the limit no matter how long it is. Looping the single-send endpoint over ten thousand numbers is the pattern the limit exists to stop.
Response headers
on every /v1 response
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 47

# on a 429, additionally:
Retry-After: 47

Core concepts

Idempotency

Every send — single or bulk — accepts an idempotency key. Replaying a request with a key that has already been used returns the original result instead of sending a second time, which makes a retry after a timeout safe.

How to supply it

Idempotency-Keyheaderoptional
8–255 characters. The form to prefer — it is the one a transparent retry layer controls, and it wins if both are present.
idempotencyKeystringoptional
The same value in the request body, for clients whose HTTP layer makes custom headers awkward.

Keys are scoped to your account, so you are free to use any scheme that is unique within it — an order id, a UUID, a hash of the message. A replay is flagged with "duplicate": true in the response so you can tell the two apart.

  • A key belongs to one request. The same key with a different request — other recipients, content, variables, schedule or mode — is a 409 IdempotencyKeyConflict carrying the original messageId or batchId, never a silent replay of something you did not ask for this time.
  • “Different” means different in meaning. JSON key order, the order of recipients in a list, letter case in an email address and an absent versus an empty cc list do not make two requests different.
  • A bulk key covers the whole batch. A retried bulk request returns the original batch — every message, the same batchId — with nothing sent or charged twice. A CSV and the same list sent as JSON count as the same request. Creation is also all-or-nothing, so a request that failed left nothing behind to collide with.
Request
header form (preferred)
curl https://infisend-api.infi-tech.cloud/v1/sms/send \
  -H "Authorization: Bearer $INFISEND_API_KEY" \
  -H "Idempotency-Key: order-4f2a-shipped" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+265991234567", "message": "Your order shipped." }'
Replay · 202
the original message, unsent twice
{
  "messageId": "msg_01HZX9K3QWERTY",
  "status": "QUEUED",
  "duplicate": true,
  …
}

Core concepts

Money & pricing

Every amount on the wire is a decimal string in MWK. What a send costs is resolved from the published rate for its channel, destination country and network at the moment it is accepted.
  • There is no fallback price. A route Infitech has published no rate for is rejected with a 422 naming the route, never guessed at — so you cannot be surprised by a rate after the fact.
  • Email is charged per recipient. One email to To + Cc + Bcc addresses costs the email rate × the number of addresses — the same as sending each a separate email. A shared email is reserved in full up front; if one recipient later fails, only that recipient’s share is refunded.
  • A reservation is a debit. The balance drops the moment a send is accepted, so two concurrent sends cannot spend the same credit while the first is still queued. There is no separate held-funds figure to reconcile.
  • Corrections are new ledger lines. Africa’s Talking prices by network, so a confirmed cost can differ from the one resolved at queue time. The difference is written as its own REFUND or DEBIT entry — a row that changes after the fact is not a ledger.
  • Refunds are idempotent. A retried worker job or a duplicate provider callback refunds only what is still outstanding against that message, so nothing pays out twice.
  • Sandbox is priced but never charged. A sandbox send reports its estimatedCost so you can size a campaign, and moves no money.
POST/messages/bulk-preview
Auth
Session token
Returns
200 — total cost, bad rows and unpriced routes

Price a recipient list before committing to it. Documented under Bulk cost preview.

Current rates are on your dashboard’s pricing screen and in the OpenAPI document.

Wallet movement
one send, end to end
balance 1000.00

POST /v1/sms/send    → DEBIT  15.00   balance 985.00
  status QUEUED

# then exactly one of:
delivered at 15.00   → no entry       balance 985.00
delivered at 12.00   → REFUND  3.00   balance 988.00
rejected / failed    → REFUND 15.00   balance 1000.00
cancelled schedule   → REFUND 15.00   balance 1000.00

Send API

Send an SMS

Debits the cost, writes the message and queues it. Returns as soon as the message is durable — it never blocks your request on Africa’s Talking.
POST/v1/sms/send
Auth
API key
Scope
SMS
Returns
202 Accepted

Body parameters

tostringrequired
Recipient MSISDN in E.164 — +265991234567. Anything else is rejected before the wallet is touched.
messagestringunless templateId
1–10,000 characters. Length is not capped at one SMS part: a long message is a multi-part send and therefore a cost question, priced by the rate table rather than refused here.
senderIdstringoptional
3–11 alphanumeric characters. Defaults to Infitech’s registered sender ID. The value must also be registered with Africa’s Talking — this checks shape, not entitlement, so an unregistered ID comes back as a provider rejection and an automatic refund.
templateIdstringoptional
Send a stored template instead of inline content. Mutually exclusive with the inline fields — supply one or the other, never both.
variablesobject<string, string>optional
Values filled into the template. Values must be strings: formatting a number, a date or an amount is a decision you have the context for and we do not. Requires templateId; sending it alone is a 400 rather than a silently unpersonalised message.
scheduledForstring (ISO 8601)optional
Queue the send for a future time instead of now. Must be in the future; there is no upper bound on the horizon. See Scheduled sends.
idempotencyKeystringoptional
8–255 characters. The Idempotency-Key header is equivalent and wins if both are present. Repeating the key with the same request returns the original result with duplicate: true — nothing is sent or charged again. The same key with a different request (other recipients, content, variables or mode) is a 409 IdempotencyKeyConflict. Key and JSON field order, recipient order and absent-versus-empty lists do not make a request different.

202 response fields

messageIdstringrequired
The id every later status event carries.
statusMessageStatusrequired
QUEUED for an immediate send, and for a scheduled one until the sweep releases it.
channelSMS | EMAILrequired
Which channel accepted it.
tostringrequired
The recipient, as stored — for an email, the first To address.
recipientCountintegerrequired
How many addresses this one message goes to: 1, or To + Cc + Bcc for a shared email — and how many units it is charged for.
recipientsarray<{ address, kind, status }>required
Email only: every recipient with its header (TO, CC, BCC) and status. Empty for SMS.
environmentSANDBOX | LIVErequired
Decided by the key you authenticated with — worth asserting on in a deploy check.
estimatedCoststring (decimal MWK)required
What the send was charged on acceptance. Reported in sandbox too, where nothing is charged.
duplicatebooleanrequired
true when this idempotency key had already been used and you are looking at the original message.
createdAtstring (ISO 8601)required
When the message row was written.

Errors

CodeCause
400Bad recipient, both or neither of message/templateId, or unsatisfied template variables.
401Missing, unknown or revoked key.
402Wallet cannot cover the send.
403Key lacks the SMS scope, the account is not live-eligible, or the recipient has opted out.
409IdempotencyKeyConflict — the key was already used for a different request. The body carries the original messageId.
422No price is configured for this route.
429Per-key rate limit exceeded.
Request
POST /v1/sms/send
curl https://infisend-api.infi-tech.cloud/v1/sms/send \
  -H "Authorization: Bearer $INFISEND_API_KEY" \
  -H "Idempotency-Key: signup-4f2a-otp" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+265991234567",
    "message": "Your verification code is 4829"
  }'
Response · 202
application/json
{
  "messageId": "msg_01HZX9K3QWERTY",
  "status": "QUEUED",
  "channel": "SMS",
  "to": "+265991234567",
  "recipientCount": 1,
  "recipients": [],
  "environment": "SANDBOX",
  "estimatedCost": "15.00",
  "duplicate": false,
  "createdAt": "2026-08-14T09:41:22.104Z"
}

Send API

Send an email

Sends one email — to one address, or to several people who all see the same message on the same thread, with To, Cc and Bcc like any mail client. To send each person their own separate email, use bulk email instead.
POST/v1/email/send
Auth
API key
Scope
EMAIL
Returns
202 Accepted

Which endpoint?

You wantSendResult
One person, one emailPOST /v1/email/send with "to": "a@x.com"One message, 1 recipient.
Several people on the same email, all visible to each otherPOST /v1/email/send with a to array and "mode": "SHARED"One message, one recipient row per address.
Copy someone, visibly or privatelycc / bcc on POST /v1/email/sendStill one message; Bcc never appears in any header.
Each person their own email (newsletter, notice, statement)POST /v1/email/bulkOne message per recipient, sharing a batchId.
Each person their own email with their own valuesPOST /v1/email/bulk with per-recipient variablesOne message per recipient; nobody sees another’s values.

Body parameters

tostring | array<string>required
One address, or an array of them. An array with more than one address must also send "mode": "SHARED" — we never guess whether you meant one email everybody sees or a separate email each.
ccarray<string>optional
Visible copies. Everyone who receives the email can see these addresses.
bccarray<string>optional
Private copies. A Bcc address receives the email but is written into no header, so no other recipient — To, Cc or another Bcc — can see it.
modeSHAREDseveral to addresses
Confirms that several to addresses are meant to share one email. Optional with a single to and with Cc/Bcc alone. INDIVIDUAL is refused here with a pointer to POST /v1/email/bulk, which is where separate emails are sent.
subjectstringinline sends
1–255 characters, no line breaks. Omitted on a templated send — the subject comes from the template.
textstringtext or html
Plain-text body, up to 200,000 characters.
htmlstringtext or html
HTML body, up to 500,000 characters. Send both and the email carries an HTML part with the text as its fallback.
replyTostringoptional
Where replies should go, when that is not the From address.
fromstringoptional
The From address. Omitted, it is Infitech’s own sending address. An address on your own domain is accepted only once that domain is verified for your account — otherwise the send is a 422 naming the domain and its status, and nothing is charged. One account can never send as another’s domain.
fromNamestringoptional
Optional. The sender name recipients see (e.g. Acme Ltd). It changes the display name only, never the address, so SPF/DKIM and deliverability are untouched. 1–80 characters; newlines are refused because the value lands in a header.
templateIdstringoptional
Send a stored template instead of inline content. Mutually exclusive with the inline fields — supply one or the other, never both.
variablesobject<string, string>optional
Values filled into the template. Values must be strings: formatting a number, a date or an amount is a decision you have the context for and we do not. Requires templateId; sending it alone is a 400 rather than a silently unpersonalised message.
scheduledForstring (ISO 8601)optional
Queue the send for a future time instead of now. Must be in the future; there is no upper bound on the horizon. See Scheduled sends.
idempotencyKeystringoptional
8–255 characters. The Idempotency-Key header is equivalent and wins if both are present. Repeating the key with the same request returns the original result with duplicate: true — nothing is sent or charged again. The same key with a different request (other recipients, content, variables or mode) is a 409 IdempotencyKeyConflict. Key and JSON field order, recipient order and absent-versus-empty lists do not make a request different.

The 202 response is the same shape as an SMS send, with recipients listing every address and the header it is in. It always describes one message, however many people it is addressed to.

An inline email needs a subject and at least one body

A templated one needs neither. Supplying inline content and a templateId is a 400, not a silent preference for one of them.

Sending one email to several people

  • Charged per recipient. A shared email costs the email rate × To + Cc + Bcc — exactly what the same number of separate emails would. The whole amount is reserved at once: if your wallet cannot cover every recipient the request is a 402 and nothing is reserved or sent.
  • Up to 50 recipients per email by default, counting To, Cc and Bcc together. Infitech can change the limit; a request over it is a 413 that states both numbers. For more people, use bulk email.
  • Each address once. An address repeated within or across to, cc and bcc is a 400 naming it — we do not guess which header you meant. Addresses are compared case-insensitively. Display names ("Mary <mary@x.com>") are not accepted: send the bare address.
  • One opted-out recipient refuses the email. If any address is on your suppression list the request is a 403 listing every such address, and nothing is sent or charged. Removing someone silently would change who the email appears to be addressed to. (Bulk sends skip suppressed recipients instead, because each person gets a separate email.)
  • Bcc stays private. Bcc addresses are given to the mail server as envelope recipients only. They never appear in the To or Cc headers, and there is no Bcc header.
  • Recipients can fail separately. If the mail server accepts some addresses and refuses others, or one address later bounces, only that recipient fails and only its share is refunded. The message becomes PARTIALLY_FAILED, and you get a message.recipient.failed webhook naming the address. See Message lifecycle.
  • Opens and clicks are counted per message. Everyone on a shared email receives the same content, so an open or a click cannot be traced to one recipient. A shared email also carries no one-click unsubscribe header, since the header cannot say which recipient is unsubscribing.

Errors

CodeCause
400Invalid address, several to addresses without "mode": "SHARED", "mode": "INDIVIDUAL", a repeated address, a subject with a line break, both or neither of inline content/templateId, or unsatisfied template variables.
401Missing, unknown or revoked key.
402Wallet cannot cover every recipient.
403Key lacks the EMAIL scope, the account is not live-eligible, or a recipient is suppressed (all of them are listed).
409IdempotencyKeyConflict — the key was already used for a different request. The body carries the original messageId.
413More recipients than one email may carry; the body gives the count and the limit.
422The From address is on a domain not verified for your account, or the route is unpriced.
429Per-key rate limit exceeded.
Request
POST /v1/email/send
curl https://infisend-api.infi-tech.cloud/v1/email/send \
  -H "Authorization: Bearer $INFISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "customer@example.com",
    "subject": "Your receipt",
    "text": "Thanks for your payment of MWK 12,500.",
    "html": "<p>Thanks for your payment of <b>MWK 12,500</b>.</p>",
    "replyTo": "billing@yourcompany.mw"
  }'
Response · 202
application/json
{
  "messageId": "msg_01HZX9K3QWERTY",
  "status": "QUEUED",
  "channel": "EMAIL",
  "to": "customer@example.com",
  "recipientCount": 1,
  "recipients": [
    { "address": "customer@example.com", "kind": "TO", "status": "QUEUED" }
  ],
  "environment": "SANDBOX",
  "estimatedCost": "5.00",
  "duplicate": false,
  "createdAt": "2026-08-14T09:41:22.104Z"
}
Request
POST /v1/email/send — one email, To + Cc + Bcc
curl https://infisend-api.infi-tech.cloud/v1/email/send \
  -H "Authorization: Bearer $INFISEND_API_KEY" \
  -H "Idempotency-Key: staff-meeting-2026-09-24" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["john@example.com", "mary@example.com"],
    "cc": ["manager@example.com"],
    "bcc": ["audit@example.com"],
    "mode": "SHARED",
    "subject": "Staff meeting moved to 14:00",
    "html": "<p>Hello everyone — the meeting is now at 14:00.</p>"
  }'
Response · 202
one shared email, four recipients
{
  "messageId": "msg_01J8SHAREDEMAIL",
  "status": "QUEUED",
  "channel": "EMAIL",
  "to": "john@example.com",
  "recipientCount": 4,
  "recipients": [
    { "address": "john@example.com",    "kind": "TO",  "status": "QUEUED" },
    { "address": "mary@example.com",    "kind": "TO",  "status": "QUEUED" },
    { "address": "manager@example.com", "kind": "CC",  "status": "QUEUED" },
    { "address": "audit@example.com",   "kind": "BCC", "status": "QUEUED" }
  ],
  "environment": "LIVE",
  "estimatedCost": "20.00",
  "duplicate": false,
  "createdAt": "2026-09-24T09:41:22.104Z"
}

Send API

Bulk SMS

One request, one message row and one queue job per recipient. Each recipient retries, fails and refunds independently — one bad number does not sink the batch.
POST/v1/sms/bulk
Auth
API key
Scope
SMS
Returns
202 Accepted

Body parameters

recipientsarray<string | object>or recipientsCsv
Either a bare E.164 string or { "to": "+265…", "variables": { … } } for a mail merge. Per-recipient variables are merged over the request-level ones, so the values everyone shares are stated once.
recipientsCsvstringor recipients
CSV text — one recipient per line, first column wins, optional header row. Sent as a string in the JSON body rather than a file upload, so there is one content type for every send. CSV rows cannot carry per-recipient variables; use the array form for that.
messagestringunless templateId
The same body for every recipient.
templateIdstringoptional
Render a stored template per recipient. Mutually exclusive with `message`.
variablesobject<string, string>optional
Applied to every recipient; a recipient’s own variables win over these.
senderIdstringoptional
As on a single send.
scheduledForstring (ISO 8601)optional
Schedules the whole batch.
idempotencyKeystringoptional
8–255 characters; the Idempotency-Key header wins. Covers the whole request: a retry with the same list and content returns the original batch with duplicate: true, and a different request under the same key is a 409 carrying the original batchId. A CSV and the same list sent as JSON count as the same request.

202 response fields

batchIdstringrequired
Shared by every message this request created. GET /v1/messages?batchId=… lists them.
batchSizeintegerrequired
How many messages were created.
totalCoststring (decimal MWK)required
Sum of what was charged across the batch.
duplicatebooleanrequired
True when an idempotent retry returned the original batch; nothing new was created.
channelSMS | EMAILrequired
The batch’s channel.
environmentSANDBOX | LIVErequired
From the key.
scheduledForstring | nullrequired
When the batch will be released, or null for immediate.
messagesarray<SendAccepted>required
One entry per recipient, in request order, in exactly the shape a single send returns — so the same code path handles both.
suppressedRecipientsarrayrequired
Recipients on your suppression list, skipped rather than sent to. Together with messages they account for every recipient you named.

All-or-nothing, and no recipient cap

The batch is created in a single transaction: if the wallet cannot cover the whole list, or any row is unusable, nothing is created and you get a 400 or 402 with zero messages behind it. There is no built-in limit on list length — use bulk preview to find bad rows and unpriced routes before you commit.
Request
POST /v1/sms/bulk
curl https://infisend-api.infi-tech.cloud/v1/sms/bulk \
  -H "Authorization: Bearer $INFISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["+265991234567", "+265881234567"],
    "message": "Polls close at 17:00."
  }'
Response · 202
application/json
{
  "batchId": "cmg1batch8x2k4",
  "batchSize": 2,
  "totalCost": "30.00",
  "duplicate": false,
  "channel": "SMS",
  "environment": "LIVE",
  "scheduledFor": null,
  "messages": [
    { "messageId": "msg_01HZX9K3QWERTY", "status": "QUEUED", "to": "+265991234567", "estimatedCost": "15.00", … },
    { "messageId": "msg_01HZX9K5ZXCVBN", "status": "QUEUED", "to": "+265881234567", "estimatedCost": "15.00", … }
  ],
  "suppressedRecipients": []
}

Send API

Bulk email

A separate email to each recipient — for newsletters, notices and statements. Nobody sees anyone else’s address, and each person’s variables fill only their own email. The same all-or-nothing guarantee as bulk SMS.
POST/v1/email/bulk
Auth
API key
Scope
EMAIL
Returns
202 Accepted

Takes recipients or recipientsCsv exactly as bulk SMS does, plus subject, text, html, replyTo, from and fromName from the single email send, and idempotencyKey as above. The response is the same BulkSendAccepted shape: one message per recipient, each with recipientCount: 1, all sharing one batchId.

  • Each address once. A repeated address, in the array or the CSV and regardless of case, is a 400 naming both rows, and nothing is created. We do not guess which row’s variables you meant, and we do not email or charge anyone twice. (Bulk SMS still accepts a repeated number.)
  • No Cc, Bcc or shared mode. Sending cc, bcc or "mode": "SHARED" here is a 400 pointing to POST /v1/email/send, never silently ignored. "mode": "INDIVIDUAL" is accepted, since it is what a bulk send already is.
  • Suppressed recipients are skipped and listed in suppressedRecipients. The others still get their emails.
Request
POST /v1/email/bulk — a separate email each
curl https://infisend-api.infi-tech.cloud/v1/email/bulk \
  -H "Authorization: Bearer $INFISEND_API_KEY" \
  -H "Idempotency-Key: balances-2026-09" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "tpl_01HZXA4F7GH",
    "recipients": [
      { "to": "john@example.com", "variables": { "name": "John", "balance": "50,000" } },
      { "to": "mary@example.com", "variables": { "name": "Mary", "balance": "25,000" } }
    ]
  }'
Request
POST /v1/email/bulk — shared and per-recipient variables
{
  "recipients": [
    { "to": "chikondi@example.com", "variables": { "firstName": "Chikondi" } },
    { "to": "thoko@example.com", "variables": { "firstName": "Thoko" } }
  ],
  "templateId": "tpl_01HZXA4F7GH",
  "variables": { "invoiceMonth": "August" },
  "replyTo": "billing@yourcompany.mw",
  "fromName": "Acme Ltd"
}

Send API

Scheduled sends

Any send — single or bulk, SMS or email — becomes a scheduled send by carrying scheduledFor. It is accepted and paid for immediately and released to the queue by a sweep when the time arrives.
  • Credit is reserved at schedule time, not at send time — so a scheduled send cannot fail later for want of balance.
  • Content is rendered at schedule time. A templated scheduled send is immune to a later edit of that template: what you scheduled is what goes out.
  • It must be in the future, with about a minute of slack for clock skew. A time already well past is refused rather than quietly sent now.
  • There is no horizon limit. A distant schedule costs you your own reserved balance and nobody else anything.

Until it fires, the message sits in QUEUED with a non-null scheduledFor and can be cancelled. Pending ones are listed by GET /messages/scheduled on the session-authenticated surface.

Request
POST /v1/sms/send
{
  "to": "+265991234567",
  "message": "Your appointment is tomorrow at 10:00.",
  "scheduledFor": "2026-08-15T06:00:00.000Z"
}

Send API

Cancel a scheduled send

Withdraws a scheduled message before the sweep releases it and refunds the reservation. What you scheduled through the API you can withdraw through the API.
POST/v1/messages/{messageId}/cancel
Auth
API key
Returns
200 — the cancelled message

Errors

CodeCause
404No such message on this account.
409Already released to the queue, or never scheduled. You lost the race — the send is going out.

The response is a full message object rather than a bare 204 because it is how you learn whether you beat the sweep. CANCELLED is its own terminal status, kept apart from REJECTED (Infitech refused it) and FAILED (it was attempted and did not arrive): only one of the three means nothing was ever tried, and it was your call.

Request
POST /v1/messages/{messageId}/cancel
curl -X POST \
  https://infisend-api.infi-tech.cloud/v1/messages/msg_01HZX9K3QWERTY/cancel \
  -H "Authorization: Bearer $INFISEND_API_KEY"
Response · 200
the cancelled message
{
  "id": "msg_01HZX9K3QWERTY",
  "status": "CANCELLED",
  "scheduledFor": "2026-08-15T06:00:00.000Z",
  "costCharged": null,
  …
}

Send API

Verify a key

Answers “what key am I holding and what may it do?”. Costs nothing, consumes no message and needs no scope — the right first call from a new deployment, and the right health check for a key rotation.
GET/v1/me
Auth
API key
Returns
200 — the key’s identity

Any valid key may ask what it is — requiring READ_ONLY would mean a send-only key could not run the snippet that verifies it. Asserting on environment here is a cheap way to catch a test key that reached production.

Response · 200
application/json
{
  "apiKeyId": "key_01HZX8Q2M4N6P8",
  "accountId": "acc_01HZX7B5C9D2E4",
  "name": "Checkout service",
  "environment": "LIVE",
  "scopes": ["SMS", "EMAIL"],
  "keyPrefix": "sk_live_9f3c2a1b"
}

Delivery

Message lifecycle

A message moves through a small, fixed set of states. Four of them are terminal, and which one a message reaches is what decides whether it was charged.

Statuses

StatusTerminalMeaningWallet
QUEUEDnoAccepted and durable. Waiting for the worker, or for its scheduled time.Debited
SENTnoThe provider accepted it. For email this is usually as far as it goes unless a bounce arrives.Charged
DELIVEREDyesThe network confirmed delivery to the handset or mailbox.Charged
FAILEDyesIt was attempted and did not arrive. failureCode and failureReason carry the provider’s explanation.Refunded
REJECTEDyesRefused before anything was sent — an unroutable recipient, a rejected sender ID.Refunded
CANCELLEDyesA scheduled message you withdrew before it was released.Refunded
PARTIALLY_FAILEDalmostShared email only: some recipients failed and the others did not. recipients says which. It moves on only to FAILED, if every remaining recipient fails later too.Failed recipients’ share refunded

Recipient statuses (email)

StatusMeaning
QUEUEDNot yet handed to the mail server.
SENTThe mail server accepted this address.
DELIVEREDDelivery to this address was confirmed.
FAILEDRefused for this address, or it bounced. Carries its own failureCode and failureReason.
CANCELLEDThe scheduled message was withdrawn.

Every email has one recipient row per address; the message status is worked out from them. A message is FAILED only when every recipient failed, PARTIALLY_FAILED when some did, and DELIVERED only when every recipient was delivered — never while one of the people it was for did not get it. With a single recipient the message status is simply that recipient’s, so a one-address email behaves exactly as it always has.

  • A bounce fails only the address it names. That recipient’s share is refunded; the others are untouched. A bounce on a shared email that does not say which address it is about is recorded as a BOUNCED event on the message, and fails and refunds no one, because guessing would be wrong for somebody.
  • Addresses that do not exist are suppressed. When a live email is permanently refused because the mailbox does not exist or the address is invalid (enhanced status codes 5.1.1, 5.1.2, 5.1.3, 5.1.6, 5.1.10, 5.2.1), the address joins your suppression list with reason PROVIDER_REPORTED. Temporary failures, full mailboxes and policy blocks do not suppress, and neither does the sandbox.

Consume the webhook, do not poll

Status changes are pushed to your endpoint. GET /v1/messages/{id} (one message, with its recipients) and GET /v1/messages (the log, filterable by batchId) are there for reconciliation — not for looping on a queued message.
Typical path
SMS, delivered
QUEUED     accepted, cost debited
   ↓
SENT       provider accepted it, debit finalised
   ↓
DELIVERED  handset confirmed  ✓ charged

# alternatives
QUEUED → REJECTED   refused before sending   ↩ refunded
SENT   → FAILED     provider or handset      ↩ refunded
QUEUED → CANCELLED  you withdrew a schedule  ↩ refunded

# a shared email (several recipients)
SENT → PARTIALLY_FAILED   some recipients failed
                          ↩ their share refunded

Delivery

Webhooks

Register an HTTPS endpoint and Infitech POSTs a signed JSON event to it on every status change you subscribed to. This is how a send’s outcome reaches you.
POST/webhooks/endpoints
Auth
Session token
Returns
201 — the endpoint, including its signing secret

Registration body

urlstringrequired
Must be https — the payload carries a phone number or an email address, and a signature proves nothing about confidentiality. http://localhost stays legal so you can test before you have a certificate.
eventsarray<string>optional
Which events to receive. Leave it empty for all of them — an endpoint that registers successfully and then receives nothing looks like a bug. Unknown names are rejected at registration rather than silently never firing.

Subscribable events

EventFires when
message.queuedThe message was accepted and its cost debited.
message.sentThe provider accepted it.
message.deliveredDelivery was confirmed.
message.failedIt was attempted and did not arrive.
message.rejectedIt was refused before sending.
message.cancelledA scheduled send was withdrawn.
message.partially_failedA shared email failed for some recipients but not all.
message.recipient.failedOne recipient of a shared email failed. Names the address and carries its failure code.
message.recipient.deliveredDelivery to one recipient of a shared email was confirmed.

The two message.recipient.* events fire only for an email with more than one recipient. For a single-recipient message the recipient’s fate is the message’s, and the message.* event already says it. The message-level event still fires whenever the overall status changes, so a handler that only understands message.* misses nothing.

The secret is shown once

whsec_… comes back from registration and from POST /webhooks/endpoints/{id}/rotate-secret, and from nowhere else — list and read responses have no field for it to leak into. Store it the way you store a database password.
Delivery request
what arrives at your endpoint
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Infitech-Gateway-Webhooks/1.0
x-infitech-signature: t=1755164487,v1=6c2f…9a1b

{ "event": "message.delivered", … }
Register
POST /webhooks/endpoints
{
  "url": "https://yourapp.mw/webhooks/infisend",
  "events": ["message.delivered", "message.failed"]
}

→ 201 { "id": "whe_01HZ…", "secret": "whsec_…" }

Delivery

Event payload

One shape for every message.* event, on both channels. The event name and the status always agree — the name is there so a router can dispatch without parsing the enum. The message.recipient.* events have their own shape, below.

Fields

eventstringrequired
One of the subscribable names above, e.g. message.delivered.
messageIdstringrequired
The id returned by the send that produced this message.
statusMessageStatusrequired
The status just reached.
channelSMS | EMAILrequired
Which channel.
tostringrequired
The recipient.
environmentSANDBOX | LIVErequired
Sandbox events are byte-identical in shape to live ones — check this field rather than assuming a test handler only ever sees test traffic.
providerMessageIdstring | nullrequired
The provider’s own reference, once there is one. Quote it in a provider dispute.
failureCodestring | nullrequired
Short machine-readable code on a failure or rejection, otherwise null.
failureReasonstring | nullrequired
The provider’s human-readable explanation.
costChargedstring | nullrequired
What this message has taken from the wallet, as a decimal string: "15.00" once charged and "0" after an auto-refund. Always null for a sandbox message, which never touches the wallet.
occurredAtstring (ISO 8601)required
When the status change happened — not when this attempt was sent. A resend replays the original value.
recipientCountintegeroptional
Email only: how many addresses the message went to. With failedCount and deliveredCount (how many have failed and been delivered so far), it lets you act on a shared email’s outcome without fetching it. Absent on SMS events; a handler that ignores them sees exactly the event it always did.
failedCountintegeroptional
Email only. See recipientCount.
deliveredCountintegeroptional
Email only. See recipientCount.

Recipient event fields

eventstringrequired
message.recipient.failed or message.recipient.delivered.
messageIdstringrequired
The shared email this recipient belongs to.
recipientIdstringrequired
Stable for this recipient. (recipientId, event) is the natural de-duplication key.
addressstringrequired
The recipient’s address.
kindTO | CC | BCCrequired
Which header the address was in. Bcc addresses are reported to you, never to other recipients.
statusRecipientStatusrequired
The status this recipient just reached.
messageStatusMessageStatusrequired
The message’s overall status after this change, e.g. PARTIALLY_FAILED.
channelEMAILrequired
Always EMAIL.
environmentSANDBOX | LIVErequired
As on message events.
providerMessageIdstring | nullrequired
The message’s provider reference.
failureCodestring | nullrequired
This recipient’s failure code — the SMTP enhanced status code where the server gave one (e.g. 5.1.1), otherwise a short code.
failureReasonstring | nullrequired
The server’s explanation for this recipient.
occurredAtstring (ISO 8601)required
When this recipient’s status changed.
Event body
application/json
{
  "event": "message.delivered",
  "messageId": "msg_01HZX9K3QWERTY",
  "status": "DELIVERED",
  "channel": "SMS",
  "to": "+265991234567",
  "environment": "LIVE",
  "providerMessageId": "ATXid_9f2c1b…",
  "failureCode": null,
  "failureReason": null,
  "costCharged": "15.00",
  "occurredAt": "2026-08-14T09:41:27.882Z"
}
Event body
a shared email, one recipient of four failed
{
  "event": "message.partially_failed",
  "messageId": "msg_01J8SHAREDEMAIL",
  "status": "PARTIALLY_FAILED",
  "channel": "EMAIL",
  "to": "john@example.com",
  "environment": "LIVE",
  "providerMessageId": "<msg_01J8SHAREDEMAIL@mail.infitech.mw>",
  "failureCode": null,
  "failureReason": null,
  "costCharged": "15.00",
  "occurredAt": "2026-09-24T09:41:31.020Z",
  "recipientCount": 4,
  "failedCount": 1,
  "deliveredCount": 0
}
Recipient event body
application/json
{
  "event": "message.recipient.failed",
  "messageId": "msg_01J8SHAREDEMAIL",
  "recipientId": "cmg1rcpt4k2x9",
  "address": "mary@example.com",
  "kind": "TO",
  "status": "FAILED",
  "messageStatus": "PARTIALLY_FAILED",
  "channel": "EMAIL",
  "environment": "LIVE",
  "providerMessageId": "<msg_01J8SHAREDEMAIL@mail.infitech.mw>",
  "failureCode": "5.1.1",
  "failureReason": "550 5.1.1 mailbox does not exist",
  "occurredAt": "2026-09-24T09:41:31.020Z"
}

Delivery

Open tracking

HTML email carries an invisible 1×1 image. When it is loaded, the message gets an OPENED event in GET /v1/messages/{id}/events.
  • HTML email only. Plain-text email has nowhere to put an image, and SMS has no open signal at all — neither ever produces an OPENED event.
  • First open only. A message has at most one OPENED event, at the first time its image was loaded. Later loads are mostly image-proxy caching and security scanners rather than people re-reading, so they are not counted.
  • Opens are not a status and do not fire a webhook; read them from the events endpoint.
  • Not in sandbox. Sandbox email never reaches a mailbox, so nobody can open it and no open is simulated.
  • Per message, not per person, on a shared email. Everyone on a shared email receives the same content, including the same image, so an open says someone opened it — not who. Send separate emails with bulk email if you need to know which person opened.

Apple Mail inflates open counts

Apple Mail Privacy Protection pre-fetches every image in a message, including the tracking image, through Apple’s proxy when the message arrives — whether or not the recipient ever opens it. For Apple Mail users an OPENED event can mean “Apple downloaded it” rather than “a person read it”, and open rates are over-reported. This is a known limitation of pixel-based open tracking on every email platform, not a bug, and there is no reliable way to tell the two apart. Treat opens as a directional signal; clicks and replies are the dependable engagement measures.

Delivery

Click tracking

Links in HTML email are rewritten to pass through a tracking domain. A click records a CLICKED event carrying the original URL, then redirects the recipient straight to it.
  • HTML email only. Links in plain-text email and SMS are sent exactly as you wrote them.
  • Never rewritten: unsubscribe and opt-out links (by URL or link text), mailto:, tel:, in-page and relative links, and links inside HTML comments. Add data-infisend-notrack to any <a> to leave that link alone.
  • Every click counts — unlike opens, a second click, or a click on a different link, is another event.
  • Your stored message is unchanged. The rewriting happens as the email is sent; the message log shows your original links.
  • Not in sandbox, for the same reason as opens.
  • Per message on a shared email, for the same reason as opens: every recipient has the same links.

Security scanners can click too

Some corporate mail filters follow every link in a message to check it before the recipient sees it. Those checks are indistinguishable from a person clicking and are counted. Clicks are a stronger signal than opens, but still a signal.

Delivery

Engagement & analytics

GET /v1/messages/{id} returns a message with an engagement summary and, for email, every recipient with its own status. GET /v1/analytics/engagement returns delivery, open and click rates for your account over a date range.
  • Per message: deliveredAt, firstOpenedAt, openCount (0 or 1 — only the first open is recorded), clickCount and clickedLinks. Open and click figures are null for SMS, which has no such signal. Status changes still belong on your webhook — do not poll this.
  • Account analytics: from (inclusive) and to (exclusive) ISO timestamps, default the last 30 days, at most 366; optional channel; environment defaults to LIVE. Figures cover messages created in the window.
  • Definitions: delivery rate is delivered ÷ attempted. Email has no delivery receipt, so an email counts as delivered once the receiving server accepted it and it has not bounced. Open and click rates are messages with at least one open or click ÷ delivered email. Rates are percentage strings to two places, null when nothing was delivered.
  • Every response carries caveats describing what its figures do not mean, so your own dashboards can show them too.

Open rate is an upper bound

Because Apple Mail Privacy Protection loads the tracking image for messages nobody opened, openRate and openCount overstate human reads for audiences using Apple Mail. Read them as an engagement signal and a ceiling, not as the number of people who read your email.

Delivery

Verifying signatures

Every delivery carries x-infitech-signature. Verify it before you trust the body — the endpoint is public, and anyone who finds it can POST to it.
The header
x-infitech-signature
t=1755164487,v1=6c2f8b41d0…9a1b
  1. Split the header on commas and read t and v1 by key, not by position — future schemes may append v2 pairs to the same header, and a parser that looks values up by name keeps working when they do.
  2. Build the signed string as `${t}.${rawBody}` — the timestamp, a literal full stop, then the request body byte for byte.
  3. HMAC-SHA-256 it with your endpoint’s whsec_… secret as UTF-8 bytes (no decoding step) and hex-encode lowercase.
  4. Compare against v1 in constant time, and reject anything where t is more than 5 minutes from your clock.

Verify against the raw body

Parsing the JSON and re-serialising it can reorder keys or change number formatting, and the digest will not match. Capture the raw bytes before any body-parser middleware touches them.

The timestamp is inside the signed string on purpose. Signing the body alone would make a captured delivery replayable forever, since the payload is byte-identical each time; with t covered, the capture expires.

Request
Express
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const TOLERANCE_SECONDS = 300;

// The raw body, not a parsed object: re-serialising JSON can reorder keys
// and will produce a different digest.
app.post('/webhooks/infisend', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('x-infitech-signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
  const raw = req.body.toString('utf8');

  const expected = crypto
    .createHmac('sha256', process.env.INFISEND_WEBHOOK_SECRET)
    .update(`${parts.t}.${raw}`)
    .digest('hex');

  const presented = Buffer.from(parts.v1 ?? '', 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  const fresh = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) <= TOLERANCE_SECONDS;

  if (!fresh || presented.length !== computed.length ||
      !crypto.timingSafeEqual(presented, computed)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(raw);
  // Acknowledge first, work afterwards — anything but a 2xx starts the retry ladder.
  res.sendStatus(200);
  handle(event);
});

Delivery

Retries & failures

Any 2xx is success. Everything else — including a 3xx, because we do not follow redirects — starts the retry ladder.
  • 5 attempts with exponential backoff. After the last one the delivery is permanently failed and surfaced to you rather than disappearing quietly.
  • Delivery is at-least-once and ordering is not guaranteed. Be idempotent on (messageId, status) for message events and on (recipientId, event) for recipient events, and ignore a status you have already advanced past.
  • A resend replays the original payload verbatim — same messageId, same status, same occurredAt — under a fresh signature timestamp. You cannot use t to tell an original from a replay.
  • Acknowledge fast. Return 200 and do the work afterwards; a handler that blocks on your own downstream call will time out and be retried.

Inspecting deliveries

EndpointPurpose
GET /webhooks/deliveriesAttempts, newest first.
GET /webhooks/deliveries/{id}One attempt, with the payload sent and the response received.
POST /webhooks/deliveries/{id}/resendReplay a logged delivery after fixing your endpoint.
Retry ladder
5 attempts, then failed
attempt 1   immediately
attempt 2   ↑ exponential backoff
attempt 3
attempt 4
attempt 5

→ delivery marked FAILED, shown in your
  dashboard's delivery log, replayable
  from POST /webhooks/deliveries/{id}/resend

Account API

Templates

Store a Handlebars template once and send it with variables. Templates are per-account and are referenced from a send by templateId.
EndpointPurpose
GET /templatesThis account’s templates.
POST /templatesCreate one.
GET /templates/{id}Read one.
PATCH /templates/{id}Edit one.
DELETE /templates/{id}Delete one.
  • Variable values are strings. Every formatting choice — thousands separators, timezone, currency symbol — is one you have the context for. What you put in the variable is exactly what the recipient reads.
  • Missing variables are a 400, not a blank in the message. A half-rendered SMS costs the same as a correct one.
  • Rendering happens when the send is accepted, so editing a template does not change a scheduled message that already referenced it.
  • An email template supplies both the subject and the body, which is why a templated email send carries neither.
Create
POST /templates
{
  "name": "Appointment reminder",
  "channel": "SMS",
  "body": "Hi {{firstName}}, your appointment is on {{date}} at {{time}}."
}
Use
POST /v1/sms/send
{
  "to": "+265991234567",
  "templateId": "tpl_01HZXA4F7GH",
  "variables": {
    "firstName": "Chikondi",
    "date": "15 August",
    "time": "10:00"
  }
}

Account API

Message log

Every message, its cost, its provider reference and its status — filterable and keyset-paginated. Sandbox and live sends are both here, distinguished by environment.
GET/messages
Auth
Session token
Returns
200 — a page of messages

Query parameters

limitintegeroptional
1–100, default 25.
cursorstringoptional
The nextCursor from the previous page. Keyset, not offset — a page cannot shift under you as new messages arrive.
statusMessageStatusoptional
Filter by status.
channelSMS | EMAILoptional
Filter by channel.
environmentSANDBOX | LIVEoptional
Filter by environment.
sinceISO 8601 datetimeoptional
Only messages created at or after this instant.
untilISO 8601 datetimeoptional
Only messages created before this instant (exclusive). Must be later than since.
searchstringoptional
A recipient fragment (case-insensitive) or an exact message ID. Matches any recipient of a shared email — To, Cc or Bcc.
batchIdstringoptional
Only the messages one bulk request created — the batchId its 202 returned.

The same log is available to API keys as GET /v1/messages, with the same parameters, so an integration can reconcile a bulk request by its batchId without a dashboard session. A key lists only messages in its own environment — a sandbox key never sees live traffic, whatever environment it asks for. Each row carries recipientCount and batchId; the recipients themselves are on the single-message read.

EndpointPurpose
GET /messages/{id}One message, with its full detail and — for email — every recipient with its own status.
GET /messages/scheduledScheduled sends that have not fired, soonest first. Unpaginated and capped at 200.
POST /messages/{id}/cancelThe dashboard’s cancel — the same call, and the same refund path, as the key-authenticated one.

Every message row carries the correlation ID of the request that created it, so a line in your logs and a line in ours can be joined without a support conversation.

Request
GET /messages
GET /messages?channel=SMS&status=FAILED&limit=50
Authorization: Bearer <session jwt>
Response · 200
application/json
{
  "messages": [ { "id": "msg_…", "status": "FAILED", … } ],
  "nextCursor": "eyJpZCI6Im1zZ18…"
}

Account API

Bulk cost preview

Price a recipient list before committing to it. Creates nothing, reserves nothing and moves no money.
POST/messages/bulk-preview
Auth
Session token
Returns
200 — cost, bad rows and unpriced routes

Problems come back as data, not as a 400

The caller is assembling a list, so answering the question by refusing to look at it would be useless. Bad addresses and unpriced routes are reported as arrays — a 400 from this endpoint means the request was malformed, not the list.

This is the endpoint to call before a bulk send, because the send itself is all-or-nothing: one unusable row rejects the entire batch. sufficientBalance answers whether a live send of this list would clear the upfront wallet check.

Request
POST /messages/bulk-preview
{
  "channel": "SMS",
  "recipients": ["+265991234567", "0991234567", "+265881234567"]
}
Response · 200
application/json
{
  "channel": "SMS",
  "recipientCount": 2,
  "totalCost": "30.00",
  "balance": "1000.00",
  "sufficientBalance": true,
  "invalidRecipients": [
    { "recipient": "0991234567", "reason": "Not E.164 — use +265991234567." }
  ],
  "unpricedRoutes": []
}

Account API

Wallet

Balance and the full ledger. Every send, refund, top-up and adjustment is a line — the balance follows from them and is never edited in place.
EndpointPurpose
GET /walletBalance, the live-key minimum, and whether the balance clears it.
GET /wallet/transactionsThe ledger, newest first, keyset-paginated.
GET /wallet/live-eligibilityWhether a live API key may be issued — KYC state plus the minimum balance, in one answer.

Transaction types

TypeMeaning
TOPUPCredit added by a completed payment.
DEBITA send’s cost, taken when the send is accepted — plus a top-up entry if the confirmed cost turns out higher.
REFUNDCredit returned after a failure, rejection or cancellation, or when the confirmed cost was lower than the one charged.
ADJUSTMENTA manual correction by Infitech, always with an audit trail.
Response · 200
GET /wallet
{
  "id": "wal_01HZX7C4D8E1F5",
  "accountId": "acc_01HZX7B5C9D2E4",
  "balance": "985.00",
  "minLiveBalance": "5000.00",
  "meetsLiveThreshold": false
}
A ledger line
GET /wallet/transactions
{
  "id": "wtx_01HZX9K4RTYUIO",
  "type": "REFUND",
  "amount": "15.00",
  "balanceAfter": "1000.00",
  "referenceType": "Message",
  "referenceId": "msg_01HZX9K3QWERTY",
  "paymentProvider": null,
  "createdAt": "2026-08-14T09:41:31.006Z"
}

Account API

Webhook endpoints

Administering the endpoints that receive the events documented above.
EndpointPurpose
GET /webhooks/endpointsRegistered endpoints.
POST /webhooks/endpointsRegister one — returns the signing secret.
GET /webhooks/endpoints/{id}One endpoint.
PATCH /webhooks/endpoints/{id}Change its URL or event subscriptions.
POST /webhooks/endpoints/{id}/rotate-secretMint a new signing secret.
DELETE /webhooks/endpoints/{id}Stop delivering to it.
Rotate a secret
POST /webhooks/endpoints/{id}/rotate-secret
→ 200 {
  "id": "whe_01HZ…",
  "secret": "whsec_…"   // shown once
}

Reference

Enums

The closed value sets that appear across requests, responses and events.
EnumValues
ChannelSMS, EMAIL
EnvironmentSANDBOX, LIVE
MessageStatusQUEUED, SENT, DELIVERED, FAILED, REJECTED, CANCELLED, PARTIALLY_FAILED
RecipientKindTO, CC, BCC
RecipientStatusQUEUED, SENT, DELIVERED, FAILED, CANCELLED
EmailSendModeSHARED, INDIVIDUAL
ApiKeyScopeSMS, EMAIL, READ_ONLY
ApiKeyStatusACTIVE, REVOKED
AccountStatusPENDING_KYC, ACTIVE, SUSPENDED
KycStatusPENDING, APPROVED, REJECTED
WalletTransactionTypeTOPUP, DEBIT, REFUND, ADJUSTMENT
WebhookEventmessage.queued, message.sent, message.delivered, message.failed, message.rejected, message.cancelled, message.partially_failed, message.recipient.failed, message.recipient.delivered
Identifiers
prefixes you will see
acc_…    account
key_…    API key
msg_…    message
tpl_…    template
whe_…    webhook endpoint

sk_live_… / sk_test_…   API key secret
whsec_…                 webhook signing secret

Reference

OpenAPI spec

The complete machine-readable specification — every route, parameter and response shape, including the administrative endpoints this page summarises.

The document is generated from the API’s own controllers and the shared validation schemas, not maintained by hand — there is no second description of any payload in the codebase, which is what makes it worth generating a client from.

Generate a client
openapi-generator
curl -O https://infisend-api.infi-tech.cloud/api/docs-json

npx @openapitools/openapi-generator-cli generate \
  -i docs-json -g typescript-fetch \
  -o ./src/infisend