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, unpriced route, invalid recipient, 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, or the account is not eligible to send live.Issue a key with the right scope, or finish KYC.
404No such message, template or endpoint on this account.Check the id. Cross-account reads return 404, not 403.
409A scheduled message has already been released to the queue, or was never scheduled.You lost the race against the sweep — the send is going out.
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 single send accepts an idempotency key. Replaying a request with a key that has already been used returns the original message instead of sending a second one, 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.

Bulk sends are not idempotent by key

A bulk request creates one message per recipient, so a single key cannot cover the batch. What makes a failed bulk request safe to retry instead is that creation is all-or-nothing: a batch the wallet cannot fully cover leaves zero rows behind.
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 400 naming the route, never guessed at — so you cannot be surprised by a rate after the fact.
  • 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.

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.
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, unpriced route, 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, or the account is not live-eligible.
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",
  "environment": "SANDBOX",
  "estimatedCost": "15.00",
  "duplicate": false,
  "createdAt": "2026-08-14T09:41:22.104Z"
}

Send API

Send an email

The same contract as SMS with the same response shape — one path segment and the content fields differ.
POST/v1/email/send
Auth
API key
Scope
EMAIL
Returns
202 Accepted

Body parameters

tostringrequired
Recipient email address.
subjectstringinline sends
1–255 characters. 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. There is no client-set from address: the envelope sender is an Infitech domain with SPF and DKIM published for it, and letting a client set it would let one client send mail that appears to come from another. fromName changes only the display name, never the address.
fromNamestringoptional
Optional. The sender name recipients see (e.g. Acme Ltd). The envelope address stays Infitech's own verified domain, 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.

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.
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",
  "environment": "SANDBOX",
  "estimatedCost": "5.00",
  "duplicate": false,
  "createdAt": "2026-08-14T09: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.

202 response fields

batchSizeintegerrequired
How many messages were created.
totalCoststring (decimal MWK)required
Sum of what was charged across the batch.
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.

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
{
  "batchSize": 2,
  "totalCost": "30.00",
  "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", … }
  ]
}

Send API

Bulk email

Identical to bulk SMS, with the email content fields and the same all-or-nothing guarantee.
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 and fromName from the single email send. Response is the same BulkSendAccepted shape.

Request
POST /v1/email/bulk
{
  "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

Consume the webhook, do not poll

There is no GET /v1/messages/{id}, by design — the alternative is every client looping on a queued message. Status changes are pushed to your endpoint; the dashboard’s session-authenticated message log is there for humans and for reconciliation.
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

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.

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

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.
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"
}

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) 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.
EndpointPurpose
GET /messages/{id}One message, with its full detail.
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
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
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