API reference
Base URL https://api.notifpool.com. JSON in, JSON out. All prices in EUR, VAT excluded. Also available as OpenAPI 3.1.
Authentication
Every request under /v1 carries your key as a bearer token. Live keys start with np_live_; test keys start with np_test_ and behave identically except that nothing is sent or charged: the message is validated, logged with "test": true and returned as sent. You get one of each at sign-up; create more with POST /v1/account/keys.
Authorization: Bearer np_live_...
SDKs
Thin, dependency-free clients that mirror this reference. Same key, same objects, same errors.
| Language | Install | Send |
|---|---|---|
| Node / Deno / Bun / Workers | npm install notifpool | await np.messages.send({ channel: "email", to, subject, text }) |
| Python 3.9+ | pip install notifpool | np.messages.send(channel="email", to=to, subject=subject, text=text) |
| PHP 8+ | composer require notifpool/notifpool | $np->send(['channel' => 'email', ...]) |
Source and READMEs: github.com/Karraz-technologies/notifpool/sdks. Each SDK ships a verifyWebhook(rawBody, signature, secret) helper.
Errors
Errors return an HTTP status and one object: { "error": { "type", "message" } }. The message says what to change.
| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request | A field is missing or malformed. The message names it. |
| 401 | authentication_error | No key, or an unknown key. |
| 402 | insufficient_balance | The send costs more than your balance. Top up first. |
| 403 | sender_not_verified | The e-mail from domain is not verified on your account. |
| 403 | insufficient_scope | The key lacks the scope this call needs. |
| 429 | rate_limited | More than 600 requests per minute on the account. |
| 429 | daily_cap | Accounts that never topped up are limited to 500 recipients per day. One top-up lifts it. |
| 503 | queue_unavailable | Could not queue the message; nothing charged. Retry. |
| 400 | suppressed | Every recipient is on your suppression list. |
| 502 | — | The carrier or mail provider refused the message. Nothing is charged; the message has status: "failed" and an error. |
Idempotency
Add an Idempotency-Key header (any string up to 200 characters) to POST /v1/messages. For 24 hours, a repeated key returns the original message with status 200 instead of sending again. Use your own order id, OTP id, or a UUID.
Send an e-mail
| Field | Required | Notes |
|---|---|---|
| channel | yes | "email" |
| to | yes | One address or an array of up to 50. Billed per recipient. |
| subject | yes | Up to 998 characters. |
| text / html | one of | Plain text, HTML, or both. Both are sent as alternatives. |
| from | no | "Name <[email protected]>". The domain must be verified. Default: Your name <[email protected]>. |
| reply_to | no | Address or array of up to 5. |
| cc / bcc | no | Arrays of addresses. to + cc + bcc up to 50; billed per recipient. |
| attachments | no | Up to 10 files, 10 MB in total. Each: filename plus either content (base64) or url (https, downloaded by us), optional content_type. Executables are refused. |
| headers | no | Extra headers, e.g. {"X-Entity-Ref-ID": "inv_42"}. Standard headers cannot be overridden. |
| metadata | no | Object of strings, returned on every read and webhook. |
curl https://api.notifpool.com/v1/messages \
-H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"to": ["[email protected]"],
"cc": ["[email protected]"],
"from": "Acme <[email protected]>",
"reply_to": "[email protected]",
"subject": "Invoice 2026-091",
"html": "<p>Your invoice is attached.</p>",
"text": "Your invoice is attached.",
"attachments": [
{ "filename": "invoice-2026-091.pdf", "url": "https://acme.io/inv/2026-091.pdf" }
],
"metadata": { "invoice": "2026-091" }
}'
For a file you already hold in memory, send it inline: { "filename": "quote.pdf", "content": "<base64>" }. Inline images for HTML: add "inline": true, "cid": "logo" and reference <img src="cid:logo">.
Response 202 Accepted. The message is queued: it has been priced, charged and handed to our delivery queue, which retries provider hiccups automatically. It becomes sent within seconds, then delivered, bounced or failed (a failure refunds the message). Watch it with the webhook or GET /v1/messages/{id}.
{
"id": "msg_4b1e9f0c2a7d5e83b6f1",
"channel": "email",
"to": ["[email protected]"],
"from": "Acme <[email protected]>",
"subject": "Invoice 2026-091",
"status": "queued",
"cost": 0.002,
"created_at": "2026-09-06T20:14:02.113Z",
"metadata": { "invoice": "2026-091" }
}
Send an SMS
| Field | Required | Notes |
|---|---|---|
| channel | yes | "sms" |
| to | yes | One number or an array of up to 50, in E.164 (+32470123456). Invalid numbers are rejected before anything is sent. |
| text | yes | Up to 918 characters. 160 GSM characters per segment (70 with emoji or non-Latin script); longer texts are split and billed per segment. |
| default_country | no | ISO code used to parse national numbers, e.g. "FR" for 0612345678. |
| from | no | An approved sender name of your account. Default: a shared short number (recipients can reply). A text containing a link, an e-mail or a phone number is signed NotifPool automatically, because the short number refuses contact details. |
| metadata | no | Object of strings. |
curl https://api.notifpool.com/v1/messages \
-H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-7f3a91" \
-d '{ "channel": "sms", "to": "+32470123456", "text": "Your code is 482 913. Valid 5 minutes." }'
{ "id": "msg_b73a…", "channel": "sms", "to": ["+32470123456"], "status": "sent",
"cost": 0.075, "segments": 1, "country": "BE", "created_at": "…" }
Read a message
Returns the message with its current status: scheduled, sent, delivered, bounced, complained, failed or cancelled, plus error when there is one.
List messages
Newest first. next_cursor is null on the last page.
Templates
Store a message once, send it with variables. Variables are {{name}}, nested {{order.total}}, with an optional default {{name | Customer}}. Values are HTML-escaped inside html, raw elsewhere.
curl https://api.notifpool.com/v1/templates -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{
"name": "invoice",
"subject": "Invoice {{number}} from {{company | Acme}}",
"html": "<p>Hello {{customer.name}},</p><p>Invoice {{number}} for {{total}} € is attached.</p>",
"text": "Hello {{customer.name}}, invoice {{number}} for {{total}} € is attached.",
"sms_text": "{{company | Acme}}: invoice {{number}} ({{total}} €) sent to your inbox.",
"from": "Acme <[email protected]>",
"tags": ["invoice"]
}'
A template can hold an e-mail (subject + text/html), an SMS (sms_text), or both. Then send it:
curl https://api.notifpool.com/v1/messages -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{
"template": "invoice",
"channel": "email",
"to": "[email protected]",
"variables": { "number": "2026-091", "total": "149.00", "customer": { "name": "Ada" } },
"attachments": [{ "filename": "invoice-2026-091.pdf", "url": "https://acme.io/inv/2026-091.pdf" }]
}'
channel defaults to e-mail when the template has a subject, else SMS. Any field you pass in the body (subject, text, from, tags…) overrides the template. Other endpoints: GET /v1/templates, GET /v1/templates/{name}, PUT /v1/templates/{name} (update), DELETE /v1/templates/{name}, and POST /v1/templates/{name}/render with { "variables": {…} } to preview without sending.
Tags and stats
Add "tags": ["invoice", "eu"] (up to 5, lowercase) to any message, or set them on a template. Filter the log with GET /v1/messages?tag=invoice, also &status=bounced and &channel=sms.
{ "from": "2026-09-01", "to": "2026-09-30", "group_by": "tag",
"total": { "sent": 5120, "delivered": 5071, "bounced": 31, "complained": 2, "failed": 16, "cost": 3.2984 },
"data": [
{ "tag": "invoice", "sent": 1240, "delivered": 1236, "bounced": 3, "complained": 0, "failed": 1, "cost": 0.744 },
{ "tag": "otp", "sent": 880, "delivered": 871, "bounced": 0, "complained": 0, "failed": 9, "cost": 66.0 }
] }
group_by is day (default), channel or tag. Counts are per recipient; the default range is the last 30 days.
Batch
Up to 100 messages in one call, e-mail and SMS mixed. Each entry is a normal message body and may carry its own idempotency_key. Entries are processed five at a time; each gets its own result, so one bad address never blocks the others.
curl https://api.notifpool.com/v1/messages/batch -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "messages": [
{ "template": "invoice", "to": "[email protected]", "variables": { "number": "2026-091" }, "idempotency_key": "inv-2026-091" },
{ "channel": "sms", "to": "+32470123456", "text": "Your code is 482 913." }
] }'
{ "total": 2, "accepted": 2, "rejected": 0,
"data": [ { "index": 0, "ok": true, "http_status": 202, "id": "msg_…", "status": "sent", … }, { "index": 1, "ok": true, "http_status": 202, … } ] }
The call returns 202 when at least one entry was accepted, 400 when none was. Rejected entries carry { "index", "ok": false, "http_status", "error" }.
Scheduling
Add "send_at": "2026-09-08T09:00:00+02:00" (ISO 8601, at least one minute ahead, at most 30 days) to any message. The message is stored with status: "scheduled", nothing is charged yet, and it is sent within a minute of the due time. Cancel it while it is still scheduled:
{ "id": "msg_…", "status": "cancelled" }
Suppression list
An address that bounces permanently or complains is added to your suppression list automatically and skipped on later sends (the message response lists it under suppressed; if every recipient is suppressed the call returns 400 suppressed). Manage the list:
Body { "email": "[email protected]", "detail": "asked to stop" }.
Removing an address lets you send to it again. Do this only when you know the mailbox works.
One-time codes
Verification codes without storing anything yourself: we generate the code, send it, and check it. Works by SMS or e-mail.
curl https://api.notifpool.com/v1/otp -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "to": "+32470123456", "ttl": 300 }'
# → { "id": "otp_…", "to": "+32470123456", "channel": "sms", "expires_at": "…", "message_id": "msg_…" }
| Field | Default | Notes |
|---|---|---|
| to | — | Phone (E.164) or e-mail address; the channel follows. |
| length | 6 | 4 to 8 digits. |
| ttl | 300 | Seconds, 60 to 1800. |
| text | "Your code is {{code}}. Valid {{minutes}} minutes." | Must contain {{code}}. subject for e-mail. |
| max_attempts | 5 | Wrong tries before the code locks. |
curl https://api.notifpool.com/v1/otp/verify -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "id": "otp_…", "code": "482913" }'
# → { "valid": true, "to": "+32470123456" }
# or { "valid": false, "reason": "wrong_code" | "expired" | "already_used" | "too_many_attempts" | "unknown" }
With a test key the code is returned in the create response so you can automate your tests. Codes are billed as one SMS or one e-mail.
SMS sender names
Without a sender name, SMS go out from a shared short number, and recipients can reply. If the text contains a link or contact details, the message is signed NotifPool instead (the short number refuses them; no reply possible). To sign with your own brand (3 to 11 letters and digits), request a sender name once; the operator reviews it within 24 to 72 hours and may ask for a company registration extract and an ID of a director.
curl https://api.notifpool.com/v1/sms/senders -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "name": "Acme", "reason": "Order confirmations and one-time codes for acme.io customers" }'
# → { "name": "Acme", "status": "pending", … }
Returns pending, approved or refused with the operator’s comment. Once approved, pass "from": "Acme" in your SMS. Some countries (France, Belgium, the UK…) ignore the name on certain carriers or require registration; the message is still delivered.
SMS replies
When a recipient replies to an SMS sent from the shared short number, the reply is attributed to the account that last wrote to that phone number and delivered as a sms.received webhook event: { "from", "text", "received_at", "in_reply_to": "msg_…" }. Replies are also listed:
Sender domains
To send e-mail from your own addresses, add the domain once and publish the DNS records returned. Verification usually completes within minutes.
curl https://api.notifpool.com/v1/domains -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "domain": "acme.io" }'
{
"domain": "acme.io",
"status": "pending",
"mail_from": { "domain": "np.acme.io", "status": "pending" },
"records": [
{ "name": "abc123._domainkey.acme.io", "type": "CNAME", "value": "abc123.dkim.amazonses.com", "purpose": "dkim (required)" },
{ "name": "def456._domainkey.acme.io", "type": "CNAME", "value": "def456.dkim.amazonses.com", "purpose": "dkim (required)" },
{ "name": "ghi789._domainkey.acme.io", "type": "CNAME", "value": "ghi789.dkim.amazonses.com", "purpose": "dkim (required)" },
{ "name": "_dmarc.acme.io", "type": "TXT", "value": "v=DMARC1; p=none;", "purpose": "dmarc (recommended)" },
{ "name": "np.acme.io", "type": "MX", "value": "feedback-smtp.eu-west-1.amazonses.com", "priority": 10, "purpose": "return-path (optional)" },
{ "name": "np.acme.io", "type": "TXT", "value": "v=spf1 include:amazonses.com ~all", "purpose": "return-path spf (optional)" }
]
}
The three DKIM records are enough to send. The two np. records are optional: with them, the Return-Path of your messages is on your own domain (better alignment for DMARC), and mail_from.status turns to success.
Re-checks and returns status: "verified" once the records resolve.
Status events
curl -X PUT https://api.notifpool.com/v1/account/webhook -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "url": "https://acme.io/hooks/notifpool" }'
# → { "webhook_url": "…", "webhook_secret": "whsec_…" }
Each event is a POST with a JSON body and two headers: X-NotifPool-Event and X-NotifPool-Signature, the hex HMAC-SHA256 of the raw body using your secret. Reply 2xx within 5 seconds; one retry follows otherwise.
{ "event": "message.delivered", "created_at": "…", "data": { "id": "msg_…", "channel": "sms", "status": "delivered", … } }
Events: message.delivered, message.bounced, message.complained, message.failed, sms.received, email.received. Send { "url": null } to stop.
Delivery guarantees
Each event is delivered at least once. If your endpoint answers anything but 2xx, or takes more than 8 seconds, we retry with growing delays (30 s, 1, 2, 4, 8, 15 min…) up to 8 attempts, then mark the event failed. Use the event id to deduplicate.
The log keeps 30 days of events with attempts and the last error; replay re-queues one for immediate delivery.
E-mail replies
Receive replies to the e-mails you send. Enable it on a verified domain: we set up reply.yourdomain; you add one MX record; then any address on that subdomain ([email protected]) delivers to your account.
{ "domain": "acme.io",
"inbound": { "address": "[email protected]",
"record": { "name": "reply.acme.io", "type": "MX", "value": "inbound-smtp.eu-west-1.amazonaws.com", "priority": 10 } } }
Use it as reply_to on outgoing messages. Each incoming e-mail becomes an email.received webhook event with from, subject, text, html (truncated), in_reply_to and attachment names, and is kept 90 days:
Messages flagged as virus are dropped; spam verdicts are passed through as spam: true.
Balance
{ "id": "acc_…", "name": "Acme", "email": "[email protected]", "plan": "pro", "plan_name": "Pro", "balance": 42.1735, "spent": 7.8265,
"sent_email": 5120, "sent_sms": 31, "webhook_url": "https://acme.io/hooks/notifpool", "created_at": "…" }
API keys
Body { "label": "staging", "mode": "live" | "test", "scopes": ["read", "send", "admin"] }. The full key is returned once. Scopes: read covers every GET, send covers sending messages and codes, admin covers everything else (keys, domains, templates, webhook, top-ups). A key without the needed scope gets 403 insufficient_scope.
The last remaining key cannot be revoked; create a replacement first.
Team
Give colleagues their own key without sharing yours. Each member gets a personal key by e-mail, limited to a role.
curl https://api.notifpool.com/v1/account/members -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "email": "[email protected]", "role": "developer" }'
| Role | Scopes |
|---|---|
| admin | read, send, admin |
| developer | read, send |
| viewer | read |
Removing a member revokes their key at once.
Top-ups
curl https://api.notifpool.com/v1/topups -H "Authorization: Bearer np_live_..." \
-H "Content-Type: application/json" -d '{ "amount": 50 }'
# → { "id": "top_…", "amount": 50, "vat": 10.5, "total": 60.5, "checkout_url": "https://www.mollie.com/checkout/…", "status": "open" }
Open checkout_url (card, Bancontact, iDEAL, SEPA). The balance is credited the moment the payment is confirmed. Packs: 10, 25, 50, 100, 250, 500 €. Bank transfer or invoice: write to [email protected] with your account id and the amount.
Prices
Public, no key needed. Returns the e-mail price and the SMS price per segment for the main countries, for each plan. Every message response also carries its exact cost.
Plans
| Plan | Per month | E-mail per 1,000 | SMS |
|---|---|---|---|
| payg | 0 € | 2.00 € | table price |
| starter | 5 € | 1.00 € | −5 % |
| pro | 12 € | 0.60 € | −10 % |
| scale | 29 € | 0.30 € | −15 % |
Your current plan is in GET /v1/account. To move to a plan, write to [email protected] for now; self-serve subscription is coming.