Jump to a section
Getting started
Core concepts
Send API
Delivery
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, 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. |
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, 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. |
404 | No such message, template or endpoint on this account. | Check the id. Cross-account reads return 404, not 403. |
409 | IdempotencyKeyConflict: 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. |
413 | An 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. |
422 | No 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. |
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.
- A key belongs to one request. The same key with a different request — other recipients, content, variables, schedule or mode — is a
409 IdempotencyKeyConflictcarrying the originalmessageIdorbatchId, 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
cclist 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.
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
422naming 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
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. Repeating the key with the same request returns the original result withduplicate: true— nothing is sent or charged again. The same key with a different request (other recipients, content, variables or mode) is a409 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.
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 — 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.
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, 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, the account is not live-eligible, or the recipient has opted out. |
409 | IdempotencyKeyConflict — the key was already used for a different request. The body carries the original messageId. |
422 | No price is configured for this route. |
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",
"recipientCount": 1,
"recipients": [],
"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
Which endpoint?
| You want | Send | Result |
|---|---|---|
| One person, one email | POST /v1/email/send with "to": "a@x.com" | One message, 1 recipient. |
| Several people on the same email, all visible to each other | POST /v1/email/send with a to array and "mode": "SHARED" | One message, one recipient row per address. |
| Copy someone, visibly or privately | cc / bcc on POST /v1/email/send | Still one message; Bcc never appears in any header. |
| Each person their own email (newsletter, notice, statement) | POST /v1/email/bulk | One message per recipient, sharing a batchId. |
| Each person their own email with their own values | POST /v1/email/bulk with per-recipient variables | One 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
toaddresses are meant to share one email. Optional with a singletoand with Cc/Bcc alone.INDIVIDUALis refused here with a pointer toPOST /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
422naming 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 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. Repeating the key with the same request returns the original result withduplicate: true— nothing is sent or charged again. The same key with a different request (other recipients, content, variables or mode) is a409 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
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
402and 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
413that states both numbers. For more people, use bulk email. - Each address once. An address repeated within or across
to,ccandbccis a400naming 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
403listing 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
ToorCcheaders, and there is noBccheader. - 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 amessage.recipient.failedwebhook 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
| Code | Cause |
|---|---|
400 | Invalid 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. |
401 | Missing, unknown or revoked key. |
402 | Wallet cannot cover every recipient. |
403 | Key lacks the EMAIL scope, the account is not live-eligible, or a recipient is suppressed (all of them are listed). |
409 | IdempotencyKeyConflict — the key was already used for a different request. The body carries the original messageId. |
413 | More recipients than one email may carry; the body gives the count and the limit. |
422 | The From address is on a domain not verified for your account, or the route is unpriced. |
429 | Per-key rate limit exceeded. |
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",
"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"
}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>"
}'{
"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
/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-Keyheader wins. Covers the whole request: a retry with the same list and content returns the original batch withduplicate: true, and a different request under the same key is a409carrying the originalbatchId. 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
messagesthey account for every recipient you named.
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."
}'{
"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
variables fill only their own email. The same all-or-nothing guarantee as bulk SMS./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
400naming 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,bccor"mode": "SHARED"here is a400pointing toPOST /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.
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" } }
]
}'{
"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 |
PARTIALLY_FAILED | almost | Shared 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)
| Status | Meaning |
|---|---|
QUEUED | Not yet handed to the mail server. |
SENT | The mail server accepted this address. |
DELIVERED | Delivery to this address was confirmed. |
FAILED | Refused for this address, or it bounced. Carries its own failureCode and failureReason. |
CANCELLED | The 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
BOUNCEDevent 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 reasonPROVIDER_REPORTED. Temporary failures, full mailboxes and policy blocks do not suppress, and neither does the sandbox.
Consume the webhook, do not poll
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.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 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. |
message.partially_failed | A shared email failed for some recipients but not all. |
message.recipient.failed | One recipient of a shared email failed. Names the address and carries its failure code. |
message.recipient.delivered | Delivery 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.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
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. 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.
recipientCountintegeroptional- Email only: how many addresses the message went to. With
failedCountanddeliveredCount(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
eventstringrequiredmessage.recipient.failedormessage.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": "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": "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
}{
"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
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
OPENEDevent. - First open only. A message has at most one
OPENEDevent, 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
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
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. Adddata-infisend-notrackto 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
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),clickCountandclickedLinks. Open and click figures arenullfor SMS, which has no such signal. Status changes still belong on your webhook — do not poll this. - Account analytics:
from(inclusive) andto(exclusive) ISO timestamps, default the last 30 days, at most 366; optionalchannel;environmentdefaults toLIVE. 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,
nullwhen nothing was delivered. - Every response carries
caveatsdescribing what its figures do not mean, so your own dashboards can show them too.
Open rate is an upper bound
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
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)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, 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.
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
batchIdits 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.
| Endpoint | Purpose |
|---|---|
GET /messages/{id} | One message, with its full detail and — for email — every recipient with its own status. |
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, PARTIALLY_FAILED |
RecipientKind | TO, CC, BCC |
RecipientStatus | QUEUED, SENT, DELIVERED, FAILED, CANCELLED |
EmailSendMode | SHARED, INDIVIDUAL |
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, message.partially_failed, message.recipient.failed, message.recipient.delivered |
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