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.

LanguageInstallSend
Node / Deno / Bun / Workersnpm install notifpoolawait np.messages.send({ channel: "email", to, subject, text })
Python 3.9+pip install notifpoolnp.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.

StatustypeMeaning
400invalid_requestA field is missing or malformed. The message names it.
401authentication_errorNo key, or an unknown key.
402insufficient_balanceThe send costs more than your balance. Top up first.
403sender_not_verifiedThe e-mail from domain is not verified on your account.
403insufficient_scopeThe key lacks the scope this call needs.
429rate_limitedMore than 600 requests per minute on the account.
429daily_capAccounts that never topped up are limited to 500 recipients per day. One top-up lifts it.
503queue_unavailableCould not queue the message; nothing charged. Retry.
400suppressedEvery 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

POST/v1/messages
FieldRequiredNotes
channelyes"email"
toyesOne address or an array of up to 50. Billed per recipient.
subjectyesUp to 998 characters.
text / htmlone ofPlain text, HTML, or both. Both are sent as alternatives.
fromno"Name <[email protected]>". The domain must be verified. Default: Your name <[email protected]>.
reply_tonoAddress or array of up to 5.
cc / bccnoArrays of addresses. to + cc + bcc up to 50; billed per recipient.
attachmentsnoUp 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.
headersnoExtra headers, e.g. {"X-Entity-Ref-ID": "inv_42"}. Standard headers cannot be overridden.
metadatanoObject 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

POST/v1/messages
FieldRequiredNotes
channelyes"sms"
toyesOne number or an array of up to 50, in E.164 (+32470123456). Invalid numbers are rejected before anything is sent.
textyesUp to 918 characters. 160 GSM characters per segment (70 with emoji or non-Latin script); longer texts are split and billed per segment.
default_countrynoISO code used to parse national numbers, e.g. "FR" for 0612345678.
fromnoAn 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.
metadatanoObject 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": "…" }
Transactional only. Marketing SMS, and anything that needs an unsubscribe clause, is not accepted on this API.

Read a message

GET/v1/messages/{id}

Returns the message with its current status: scheduled, sent, delivered, bounced, complained, failed or cancelled, plus error when there is one.

List messages

GET/v1/messages?limit=50&cursor=…

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.

POST/v1/templates
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.

GET/v1/stats?from=2026-09-01&to=2026-09-30&group_by=tag
{ "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

POST/v1/messages/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:

DELETE/v1/messages/{id}
{ "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:

GET/v1/suppressions
POST/v1/suppressions

Body { "email": "[email protected]", "detail": "asked to stop" }.

DELETE/v1/suppressions/{email}

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.

POST/v1/otp
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_…" }
FieldDefaultNotes
to—Phone (E.164) or e-mail address; the channel follows.
length64 to 8 digits.
ttl300Seconds, 60 to 1800.
text"Your code is {{code}}. Valid {{minutes}} minutes."Must contain {{code}}. subject for e-mail.
max_attempts5Wrong tries before the code locks.
POST/v1/otp/verify
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.

POST/v1/sms/senders
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", … }
GET/v1/sms/senders/{name}

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:

GET/v1/sms/inbound

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.

POST/v1/domains
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.

GET/v1/domains/{domain}

Re-checks and returns status: "verified" once the records resolve.

GET/v1/domains
DELETE/v1/domains/{domain}

Status events

PUT/v1/account/webhook
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.

GET/v1/webhooks/events?status=failed
GET/v1/webhooks/events/{id}
POST/v1/webhooks/events/{id}/replay

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.

POST/v1/domains/{domain}/inbound
{ "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:

GET/v1/email/inbound
GET/v1/email/inbound/{id}

Messages flagged as virus are dropped; spam verdicts are passed through as spam: true.

Balance

GET/v1/account
{ "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

GET/v1/account/keys
POST/v1/account/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.

DELETE/v1/account/keys/{id}

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.

POST/v1/account/members
curl https://api.notifpool.com/v1/account/members -H "Authorization: Bearer np_live_..." \
  -H "Content-Type: application/json" -d '{ "email": "[email protected]", "role": "developer" }'
RoleScopes
adminread, send, admin
developerread, send
viewerread
GET/v1/account/members
DELETE/v1/account/members/{email}

Removing a member revokes their key at once.

Top-ups

POST/v1/topups
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.

GET/v1/topups

Prices

GET/v1/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

PlanPer monthE-mail per 1,000SMS
payg0 €2.00 €table price
starter5 €1.00 €−5 %
pro12 €0.60 €−10 %
scale29 €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.