Jump to a section
Getting started
Core concepts
Send API
Reference
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
What to know before the first call
| Property | Behaviour |
|---|---|
| Transport | HTTPS only. JSON request and response bodies throughout. |
| Sending is asynchronous | A 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 string | Amounts 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. |
| Timestamps | ISO 8601 with a timezone, always UTC — 2026-08-14T09:41:22.104Z. |
| Tracing | Every 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. |
https://infisend-api.infi-tech.cloudAuthorization: Bearer sk_live_…
Content-Type: application/json
x-correlation-id: <optional, echoed back>Getting started
Quickstart
- Create an account. Your account and its wallet exist immediately — no KYC, no top-up.
- Generate a sandbox key on the API keys screen and pick its scopes. The secret is shown exactly once.
- Call
GET /v1/mewith it (right) to confirm it works. - 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
curl https://infisend-api.infi-tech.cloud/v1/me \
-H "Authorization: Bearer $INFISEND_API_KEY"{
"apiKeyId": "key_01HZX8Q2M4N6P8",
"accountId": "acc_01HZX7B5C9D2E4",
"name": "Checkout service",
"environment": "SANDBOX",
"scopes": ["SMS", "EMAIL"],
"keyPrefix": "sk_test_a1b2c3d4"
}Core concepts
Authentication
Authorization header — but they are not interchangeable, and which one an endpoint takes is part of its contract.| Credential | Looks like | Can do | Cannot do |
|---|---|---|---|
| API key | sk_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 token | JWT from /auth/login | Administer 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/sendand/v1/sms/bulk. A key without it gets a403on those routes. EMAILscopeoptional- The same for
/v1/email/sendand/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
sk_test_a1b2c3d4-style display prefix. Lose it and the fix is a rotation, not a support ticket.Authorization: Bearer sk_live_9f3c…
# sandbox keys are prefixed sk_test_POST /auth/login
{ "email": "you@company.mw", "password": "…" }
→ Authorization: Bearer <jwt>Core concepts
Environments
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.| Sandbox | Live | |
|---|---|---|
| Key prefix | sk_test_ | sk_live_ |
| Available | The moment the account exists | After KYC approval and a funded wallet |
| Wallet | Priced, never charged | Debited on accept, refunded or corrected on outcome |
| Provider | Simulator with realistic delays and failures | Africa's Talking / SMTP |
| Status transitions | Identical | Identical |
| Webhooks | Identical payloads and signatures | Identical 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.
# 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
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
| Code | Meaning | What to do |
|---|---|---|
400 | Malformed body, unpriced route, invalid recipient, or template variables not satisfied. | Fix the request. Retrying it unchanged will not help. |
401 | Missing, unknown or revoked API key. | Check the header format and that the key has not been revoked. |
402 | The wallet cannot cover this send. | Top up. Nothing was created and nothing was charged. |
403 | The 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. |
404 | No such message, template or endpoint on this account. | Check the id. Cross-account reads return 404, not 403. |
409 | A 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. |
429 | Per-key rate limit exceeded. | Back off for Retry-After seconds. |
5xx | Our fault. | Retry with the same idempotency key — it cannot double-send. |
A failed send is not a charge
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.{
"statusCode": 402,
"message": "Insufficient wallet balance: 12.00 available, 15.00 required.",
"error": "Payment Required"
}{
"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
/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
/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.X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 47
# on a 429, additionally:
Retry-After: 47Core concepts
Idempotency
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
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." }'{
"messageId": "msg_01HZX9K3QWERTY",
"status": "QUEUED",
"duplicate": true,
…
}Core concepts
Money & pricing
- There is no fallback price. A route Infitech has published no rate for is rejected with a
400naming 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
REFUNDorDEBITentry — 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
estimatedCostso you can size a campaign, and moves no money.
/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.
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.00Send API
Send an SMS
/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 a400rather 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-Keyheader is equivalent and wins if both are present.
202 response fields
messageIdstringrequired- The id every later status event carries.
statusMessageStatusrequiredQUEUEDfor 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.
duplicatebooleanrequiredtruewhen 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
| Code | Cause |
|---|---|
400 | Bad recipient, unpriced route, both or neither of message/templateId, or unsatisfied template variables. |
401 | Missing, unknown or revoked key. |
402 | Wallet cannot cover the send. |
403 | Key lacks the SMS scope, or the account is not live-eligible. |
429 | Per-key rate limit exceeded. |
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"
}'{
"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
/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
fromaddress: 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.fromNamechanges 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 a400rather 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-Keyheader is equivalent and wins if both are present.
An inline email needs a subject and at least one body
templateId is a 400, not a silent preference for one of them.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"
}'{
"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
/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
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.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."
}'{
"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
/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.
{
"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
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.
{
"to": "+265991234567",
"message": "Your appointment is tomorrow at 10:00.",
"scheduledFor": "2026-08-15T06:00:00.000Z"
}Send API
Cancel a scheduled send
/v1/messages/{messageId}/cancel- Auth
- API key
- Returns
- 200 — the cancelled message
Errors
| Code | Cause |
|---|---|
404 | No such message on this account. |
409 | Already 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.
curl -X POST \
https://infisend-api.infi-tech.cloud/v1/messages/msg_01HZX9K3QWERTY/cancel \
-H "Authorization: Bearer $INFISEND_API_KEY"{
"id": "msg_01HZX9K3QWERTY",
"status": "CANCELLED",
"scheduledFor": "2026-08-15T06:00:00.000Z",
"costCharged": null,
…
}Send API
Verify a key
/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.
{
"apiKeyId": "key_01HZX8Q2M4N6P8",
"accountId": "acc_01HZX7B5C9D2E4",
"name": "Checkout service",
"environment": "LIVE",
"scopes": ["SMS", "EMAIL"],
"keyPrefix": "sk_live_9f3c2a1b"
}Delivery
Message lifecycle
Statuses
| Status | Terminal | Meaning | Wallet |
|---|---|---|---|
QUEUED | no | Accepted and durable. Waiting for the worker, or for its scheduled time. | Debited |
SENT | no | The provider accepted it. For email this is usually as far as it goes unless a bounce arrives. | Charged |
DELIVERED | yes | The network confirmed delivery to the handset or mailbox. | Charged |
FAILED | yes | It was attempted and did not arrive. failureCode and failureReason carry the provider’s explanation. | Refunded |
REJECTED | yes | Refused before anything was sent — an unroutable recipient, a rejected sender ID. | Refunded |
CANCELLED | yes | A scheduled message you withdrew before it was released. | Refunded |
Consume the webhook, do not poll
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.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 ↩ refundedDelivery
Webhooks
/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://localhoststays 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
| Event | Fires when |
|---|---|
message.queued | The message was accepted and its cost debited. |
message.sent | The provider accepted it. |
message.delivered | Delivery was confirmed. |
message.failed | It was attempted and did not arrive. |
message.rejected | It was refused before sending. |
message.cancelled | A 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.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", … }{
"url": "https://yourapp.mw/webhooks/infisend",
"events": ["message.delivered", "message.failed"]
}
→ 201 { "id": "whe_01HZ…", "secret": "whsec_…" }Delivery
Event payload
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. Alwaysnullfor 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": "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
x-infitech-signature. Verify it before you trust the body — the endpoint is public, and anyone who finds it can POST to it.t=1755164487,v1=6c2f8b41d0…9a1b- Split the header on commas and read
tandv1by key, not by position — future schemes may appendv2pairs to the same header, and a parser that looks values up by name keeps working when they do. - Build the signed string as
`${t}.${rawBody}`— the timestamp, a literal full stop, then the request body byte for byte. - HMAC-SHA-256 it with your endpoint’s
whsec_…secret as UTF-8 bytes (no decoding step) and hex-encode lowercase. - Compare against
v1in constant time, and reject anything wheretis more than 5 minutes from your clock.
Verify against the raw body
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.
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
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, samestatus, sameoccurredAt— under a fresh signature timestamp. You cannot usetto tell an original from a replay. - Acknowledge fast. Return
200and do the work afterwards; a handler that blocks on your own downstream call will time out and be retried.
Inspecting deliveries
| Endpoint | Purpose |
|---|---|
GET /webhooks/deliveries | Attempts, newest first. |
GET /webhooks/deliveries/{id} | One attempt, with the payload sent and the response received. |
POST /webhooks/deliveries/{id}/resend | Replay a logged delivery after fixing your endpoint. |
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}/resendAccount API
Templates
templateId.| Endpoint | Purpose |
|---|---|
GET /templates | This account’s templates. |
POST /templates | Create 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.
{
"name": "Appointment reminder",
"channel": "SMS",
"body": "Hi {{firstName}}, your appointment is on {{date}} at {{time}}."
}{
"to": "+265991234567",
"templateId": "tpl_01HZXA4F7GH",
"variables": {
"firstName": "Chikondi",
"date": "15 August",
"time": "10:00"
}
}Account API
Message log
environment./messages- Auth
- Session token
- Returns
- 200 — a page of messages
Query parameters
limitintegeroptional- 1–100, default 25.
cursorstringoptional- The
nextCursorfrom 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.
| Endpoint | Purpose |
|---|---|
GET /messages/{id} | One message, with its full detail. |
GET /messages/scheduled | Scheduled sends that have not fired, soonest first. Unpaginated and capped at 200. |
POST /messages/{id}/cancel | The 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.
GET /messages?channel=SMS&status=FAILED&limit=50
Authorization: Bearer <session jwt>{
"messages": [ { "id": "msg_…", "status": "FAILED", … } ],
"nextCursor": "eyJpZCI6Im1zZ18…"
}Account API
Bulk cost preview
/messages/bulk-preview- Auth
- Session token
- Returns
- 200 — cost, bad rows and unpriced routes
Problems come back as data, not as a 400
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.
{
"channel": "SMS",
"recipients": ["+265991234567", "0991234567", "+265881234567"]
}{
"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
| Endpoint | Purpose |
|---|---|
GET /wallet | Balance, the live-key minimum, and whether the balance clears it. |
GET /wallet/transactions | The ledger, newest first, keyset-paginated. |
GET /wallet/live-eligibility | Whether a live API key may be issued — KYC state plus the minimum balance, in one answer. |
Transaction types
| Type | Meaning |
|---|---|
TOPUP | Credit added by a completed payment. |
DEBIT | A send’s cost, taken when the send is accepted — plus a top-up entry if the confirmed cost turns out higher. |
REFUND | Credit returned after a failure, rejection or cancellation, or when the confirmed cost was lower than the one charged. |
ADJUSTMENT | A manual correction by Infitech, always with an audit trail. |
{
"id": "wal_01HZX7C4D8E1F5",
"accountId": "acc_01HZX7B5C9D2E4",
"balance": "985.00",
"minLiveBalance": "5000.00",
"meetsLiveThreshold": false
}{
"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
| Endpoint | Purpose |
|---|---|
GET /webhooks/endpoints | Registered endpoints. |
POST /webhooks/endpoints | Register 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-secret | Mint a new signing secret. |
DELETE /webhooks/endpoints/{id} | Stop delivering to it. |
→ 200 {
"id": "whe_01HZ…",
"secret": "whsec_…" // shown once
}Reference
Enums
| Enum | Values |
|---|---|
Channel | SMS, EMAIL |
Environment | SANDBOX, LIVE |
MessageStatus | QUEUED, SENT, DELIVERED, FAILED, REJECTED, CANCELLED |
ApiKeyScope | SMS, EMAIL, READ_ONLY |
ApiKeyStatus | ACTIVE, REVOKED |
AccountStatus | PENDING_KYC, ACTIVE, SUSPENDED |
KycStatus | PENDING, APPROVED, REJECTED |
WalletTransactionType | TOPUP, DEBIT, REFUND, ADJUSTMENT |
WebhookEvent | message.queued, message.sent, message.delivered, message.failed, message.rejected, message.cancelled |
acc_… account
key_… API key
msg_… message
tpl_… template
whe_… webhook endpoint
sk_live_… / sk_test_… API key secret
whsec_… webhook signing secretReference
OpenAPI spec
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.
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