#Overview
The API sends transactional email: sign-in codes, receipts and notices about a person’s own account. It is a JSON API over HTTPS. Every request carries your API key; every answer is JSON.
| Base address | https://api.nexdeliv.com |
|---|---|
| Format | JSON in, JSON out (UTF-8) |
| Authentication | Authorization: Bearer <your key> |
| Dates and days | UTC |
Bulk or unsolicited mail is not allowed; see the Terms.
#Quick start
- Create an account on the Get started page and choose a name for your sending account. You start on the Free plan at once: no card, up to 100 emails a day.
- In the dashboard, add the domain you send from. Add the three DNS records shown, then choose “Check again” until it says Verified.
- In the dashboard, create an API key. It is shown once: store it as a secret in your application (for example the NEXDELIV_KEY environment variable).
- Send an email with the request below. A 202 answer means it was accepted.
#Authentication
Send your key in the Authorization header: Authorization: Bearer nxd_… . A missing, unknown or disabled key all get the same answer, 401 unauthorized, so nothing is revealed about which keys exist.
- A key is shown once, when it is created. We keep only a one-way hash of it.
- If a key may have leaked, replace it in the dashboard (see API keys): the old one stops working at once. Your limits and counters stay.
#Send an email
POST/v1/email
POST /v1/email sends one message. It may go to several people with to, cc and bcc, up to 10 different addresses in all; each address counts as one email of your plan.
| Field | Required | Meaning |
|---|---|---|
| to | yes | An email address, or a list of them (up to 10 in all with cc and bcc). Each is up to 254 characters. |
| cc | no | An address or a list of them, shown to every recipient. |
| bcc | no | An address or a list of them, hidden from the others. |
| subject | yes | Up to 200 characters, one line (no line breaks). |
| text | yes | The plain-text body, up to 20,000 characters. Every message needs it, even when you also send html. |
| html | no | An HTML version, up to 100,000 characters. |
| from | no | The sender, as address@domain or Name <address@domain>. It must be on a verified domain of your account. When left out, your account’s default sender is used. The display name is limited to 80 characters. |
| replyTo | no | An address, or a list of up to 3, that replies go to. |
| headers | no | An object of extra headers, for example {"List-Unsubscribe": "<mailto:[email protected]>"}. Up to 10, plain ASCII, a value of up to 500 characters. Headers that the message itself sets (From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, Content-Type, MIME-Version, Return-Path and a few more) are refused. |
| attachments | no | Files to send with it, on a plan that has them (all three, once you have paid): a list of up to 5 objects {"filename": "receipt.pdf", "content": "<base64>", "contentType": "application/pdf"}; contentType is optional. See Attachments below. |
| template | no | The name of a stored template, instead of subject, text and html; vars gives the values for its variables. See Email templates below. |
| unsubscribe | no | true makes it a message that can be stopped: it carries a link with which the one recipient stops getting such messages. See Unsubscribe below. |
| track | no | {"opens": true, "clicks": true} asks for the opens and the clicks of this message to be told to your webhook. Off unless you ask. Needs html and one recipient. See Opens and clicks below. |
An address that is written twice (in any case) counts once. If one address on the list bounced or complained before, the whole message is refused with 422 recipient_suppressed, so remove that address and send again. You can also try every field from the dashboard, under Send test.
curl -X POST https://api.nexdeliv.com/v1/email \
-H "Authorization: Bearer $NEXDELIV_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Your App <[email protected]>",
"to": ["[email protected]", "[email protected]"],
"cc": "[email protected]",
"bcc": ["[email protected]"],
"replyTo": ["[email protected]"],
"subject": "Your invoice",
"text": "Your invoice is attached to your account.",
"headers": {
"List-Unsubscribe": "<mailto:[email protected]>",
"X-Campaign": "invoices"
}
}'curl -X POST https://api.nexdeliv.com/v1/email \
-H "Authorization: Bearer $NEXDELIV_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Your App <[email protected]>",
"to": "[email protected]",
"subject": "Your sign-in code",
"text": "Your code is 482913.",
"html": "<p>Your code is <strong>482913</strong>.</p>",
"replyTo": "[email protected]"
}'HTTP/1.1 202 Accepted
Content-Type: application/json
{ "id": "010e01a17b5ad26e-10055656-9b2f-4b83-8369-48e48a22c42c-000000" }The id is the message id. Keep it if you want to match webhook events to your own records: every webhook event carries the same messageId.
Idempotency-Key (optional header): if you did not get the answer (a timeout, a dropped connection), send the same request again with the same Idempotency-Key, 1 to 255 printable ASCII characters (a UUID is fine), and it will not be sent twice. Within 24 hours the same key with the same body returns the original 202 with the same id and the header Idempotent-Replayed: true, and nothing is sent or counted again. The same key with a different body is answered 422 idempotency_key_reused; the same key while the first request is still running, 409 idempotency_in_progress (wait a second). If a request fails (for example 502 send_failed), the key is free again and a retry sends. Use a new key for each new message.
#Email templates
Keep the subject and the body of an email that you send often under a name, with {{variables}} where the values go, and send it with template and vars instead of writing the message each time. You make and change templates in the dashboard, under Templates (or with the routes below, signed in with your account, not with an API key).
curl -X POST https://api.nexdeliv.com/v1/email \
-H "Authorization: Bearer $NEXDELIV_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Your App <[email protected]>",
"to": "[email protected]",
"template": "welcome",
"vars": { "name": "Arif", "code": "482913" }
}'| Field | Meaning |
|---|---|
| template | The name of a template of your account: lowercase letters, digits and hyphens, 1 to 64 characters. It cannot be combined with subject, text or html (400 invalid_template_use). to, cc, bcc, from, replyTo, headers, attachments, unsubscribe and track work as always. |
| vars | An object of values for the variables: {"name": "Arif"}. A value is a text, a number or true/false. Up to 50 variables, a value of up to 2,000 characters. A variable name is letters, digits and underscore. |
- A variable is written {{name}}, exactly like that (no spaces inside). It is replaced by its value, once: there are no loops, conditions or includes, and a value that itself contains {{x}} is inserted as it is.
- In html a value is HTML-escaped (a < becomes <), so a name or a text from your users cannot add markup. In the subject and the text it is inserted as it is, and a value with a line break there is refused (400 invalid_vars), because a line break could add a header or rewrite the message.
- Every variable that the template uses must be in vars, or the answer is 400 missing_variable. A variable in vars that the template does not use is ignored. An unknown template is 404 template_not_found.
- Only {{name}} written exactly like that is a variable. Something that looks like one but is not ({{ name }} with spaces, {{first-name}} with a hyphen) would be sent as it is, so a template that has one is refused when you save it (400 invalid_placeholder). A message sent without a template is refused too if its subject, text or html has a {{...}} in it (400 unfilled_placeholder), because variables are filled only for a template; send "raw": true if the braces are meant.
- One template has one language. For a message in several languages keep one template for each, with the language at the end of the name (welcome-en, welcome-id), and let your application choose the name.
- {{unsubscribe_url}} is not a variable. Write it in a template as always and send with "unsubscribe": true: we fill it in with the link, as for any message. It cannot be set through vars (400 invalid_vars).
- The message that comes out of the template is checked as any other: the subject is up to 200 characters on one line, the text up to 20,000 and the html up to 100,000, after the values are in. The limits of your plan and the suppression list apply as always.
- How many templates you can keep depends on your plan: Starter 10, Basic 50, Pro 200, Free 3. A template has a subject (up to 200 characters), a text and, if you like, an html, with up to 50 different variables.
The routes behind the dashboard, for your own tools (sign-in token of the account, not an API key; an account that is switched off gets 403 account_disabled):
| Route | What it does |
|---|---|
| GET /v1/account/templates | The names and subjects of your templates, and how many your plan allows. |
| GET /v1/account/templates/{name} | One template with its text, html and the variables it uses. |
| PUT /v1/account/templates/{name} | Makes or replaces a template. Body: {"subject": "...", "text": "...", "html": "..."} (html may be left out). 409 templates_limit when it would be one more than your plan allows. |
| DELETE /v1/account/templates/{name} | Deletes a template. A message that names it afterwards is answered 404 template_not_found. |
#Attachments
Every paid plan can send files with an email (the Free plan cannot: nobody has paid yet, and a file is how phishing and malware travel). Put them in attachments, each as a name and the bytes in base64. The body of the email (text, and html if you send it) stays the same; the files go as ordinary attachments.
| Field | Required | Meaning |
|---|---|---|
| filename | yes | The name the recipient sees, with its extension (receipt.pdf). Up to 255 characters, no / \ : * ? " < > | and no line breaks. |
| content | yes | The file, in base64 (standard alphabet). Not empty. |
| contentType | no | The media type, for example application/pdf. When left out it follows from the extension. |
- Up to 5 files, and up to 4 MB of files in all for one request (the size of the files, before base64).
- File types that our delivery system refuses (programs and scripts such as .exe, .bat, .js, .vbs, .msi, .ps1) are answered 400 attachment_type_not_allowed.
- Each plan has a volume of files a month (UTC), counted for each recipient, because every copy sent costs us: Starter 1 GB, Basic 3 GB, Pro 10 GB. Over it, 429 attachments_limit; emails without files are not stopped by it. During the free trial there are none: 403 attachments_not_allowed.
- We do not keep the files, and the history shows only the addresses, the time and the status.
curl -X POST https://api.nexdeliv.com/v1/email \
-H "Authorization: Bearer $NEXDELIV_KEY" \
-H "Content-Type: application/json" \
-d "{
"from": "Your App <[email protected]>",
"to": "[email protected]",
"subject": "Your receipt",
"text": "Your receipt is attached.",
"attachments": [{ "filename": "receipt.pdf", "content": "$(base64 < receipt.pdf | tr -d '\n')" }]
}"#Unsubscribe
For a message that people may want to stop (news, reminders, notices that are not about the person’s own account), send "unsubscribe": true. We add the header List-Unsubscribe (with List-Unsubscribe-Post, so that Gmail and others offer one click) with a link of ours, and we put the same link in the body wherever you write {{unsubscribe_url}}, in text and in html.
curl -X POST https://api.nexdeliv.com/v1/email \
-H "Authorization: Bearer $NEXDELIV_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Your App <[email protected]>",
"to": "[email protected]",
"subject": "What is new this month",
"text": "Hello! Here is what is new.\n\nStop getting these emails: {{unsubscribe_url}}",
"html": "<p>Hello! Here is what is new.</p><p><a href=\"{{unsubscribe_url}}\">Stop getting these emails</a></p>",
"unsubscribe": true
}'- One recipient: such a message has a single address in to, no cc and no bcc, because the link belongs to that person. Several people, several requests.
- The link opens a small page that asks for one confirmation (a mail scanner that opens the link cannot unsubscribe anyone). The person is on your suppression list then, with the reason unsubscribe, and never leaves it by itself. You see it under Suppression, where an address with that reason can be removed by hand on a plan that manages its list.
- It stops only the messages sent with "unsubscribe": true. The sign-in code, the receipt and every other message without the field still reach the same address. Such a message to someone who stopped is answered 422 recipient_unsubscribed and is not counted. An address that bounced is held back for every message, with 422 recipient_suppressed.
- Do not set List-Unsubscribe or List-Unsubscribe-Post in headers yourself when you use this: that is answered 400 invalid_unsubscribe. Without the field, headers keeps working as before, for a link of your own.
- To know when it happens, choose the event email.unsubscribed under Webhook in the dashboard (see Webhooks). It is sent once, the first time.
#Opens and clicks
For a message where it matters whether it was read or what was followed (news, a receipt with a link to pay), send "track": {"opens": true, "clicks": true}, or only one of them. Then choose the events email.opened and email.clicked under Webhook in the dashboard, and each is told to your webhook.
curl -X POST https://api.nexdeliv.com/v1/email \
-H "Authorization: Bearer $NEXDELIV_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Your App <[email protected]>",
"to": "[email protected]",
"subject": "Your receipt",
"text": "Your receipt: https://your-domain.com/receipts/123",
"html": "<p>Your receipt is <a href=\"https://your-domain.com/receipts/123\">here</a>.</p>",
"track": { "opens": true, "clicks": true }
}'- It is off for every message that does not ask, and you should leave it off for sign-in codes, password resets and every message whose link must be exactly what you wrote: with clicks on, our delivery system sends each link of the html through its own server before it reaches yours, which adds a step and can make a link look less trustworthy.
- It needs html (an open is a one-pixel image in the html, a click is a link of the html; plain text is not tracked) and one recipient (the links of a message are the same for everybody it goes to, so a click could not tell who clicked). Otherwise 400 invalid_track.
- Opens are a hint. Many mail programs load the images for the person before they read, or hide when they read, and some never load them. Clicks are better, but mail scanners follow every link: bot is true when we think a program did it.
- You get the address, the time and, for a click, the link as you wrote it. We never pass on the IP address or the device of the person, and we keep nothing of it.
- A link that is not written as a correctly encoded address (spaces in the query, for example) is not tracked. The link to unsubscribe that we add (see Unsubscribe) is left out of the tracking.
#Answers and errors
A problem is always an HTTP status and a JSON body with an error code, for example {"error":"invalid_to"}. Only 202 means the message was accepted for sending.
| Status | error | What to do |
|---|---|---|
| 202 | (none; the body has id) | Accepted. Delivery or bounce is reported later by webhook. |
| 400 | invalid_body, invalid_to, invalid_cc, invalid_bcc, too_many_recipients, invalid_subject, invalid_text, invalid_html, invalid_reply_to, invalid_headers, invalid_from, invalid_attachments, attachment_type_not_allowed, attachments_too_large, invalid_unsubscribe, invalid_track, invalid_template_use, invalid_vars, missing_variable, unfilled_placeholder | The request is wrong. Fix it; do not retry as it is. |
| 404 | template_not_found | The template named in template does not exist in your account. Related 400 codes: invalid_template_use (template together with subject, text or html), missing_variable (the template uses a variable that vars does not have) and invalid_vars (a value that is not allowed; see Email templates). |
| 401 | unauthorized | The key is missing, wrong or the account is switched off. |
| 403 | from_not_allowed, attachments_not_allowed | from_not_allowed: the sender is not on a domain of your account. attachments_not_allowed: your account is on the Free plan, or has no plan with attachments. Starter, Basic and Pro have them once paid. |
| 422 | recipient_suppressed | An address in to, cc or bcc bounced for good or complained before, so it is not mailed again. Nothing counts against your limits. |
| 423 | sending_held | Sending is paused on your account because the bounces or complaints of the last 7 days are high (5 percent or more, or 0.1 percent or more, with at least 50 answers). Nothing is sent or counted. We email you when it happens. Clean your list (the suppression list shows what bounced), then write to [email protected]; it also lifts by itself, after at least a day, once the rates are under the limits again. Do not retry in a loop. |
| 422 | recipient_typo, recipient_invalid_domain | The domain of an address in to, cc or bcc is a known typo of a common provider (recipient_typo, for example gmial.com), or does not exist or takes no email (recipient_invalid_domain: no MX record, no address record, or a null MX). The body also has address and, where we can tell, suggestion (the address that was probably meant). Nothing is sent or counted. We look only at the domain; an address that does not exist at a real domain is still accepted and may bounce. If our DNS check cannot get an answer, the message goes on. |
| 422 | recipient_unsubscribed | The one recipient of a message sent with "unsubscribe": true asked to stop. Nothing counts against your limits. Messages without that field still go to the address. |
| 402 | credit_empty | The plan has ended and the prepaid emails do not cover this request (fewer left than recipients, or the balance ran out of time while the request was handled). Buy more in the dashboard. |
| 402 | plan_expired | The month you paid for ended more than three days ago. Sending is off until you pay again in the dashboard. |
| 402 | plan_canceled | The subscription was cancelled. Nothing is sent until a plan is switched on again in the dashboard. |
| 429 | rate_limited, busy, monthly_limit, daily_limit, free_capacity, attachments_limit | rate_limited: you sent more requests than your plan allows: its requests a second on average, with a burst of 5 times that at once. Wait (the Retry-After header says how many seconds until the next window) and send again. busy: all customers together are at the speed our email provider allows; wait a second and send again, nothing was counted. monthly_limit: you used the emails of your plan for this month (UTC); buy a top-up or move to a larger plan in the Plan section of the dashboard, or try again on the 1st (see “Running out of emails”). daily_limit: only on the Free plan, which has a limit per UTC day instead of a monthly one. free_capacity: all the free accounts share a room of emails for each UTC day and it is full; the Retry-After header says how many seconds until 00:00 UTC, when it opens again. Prepaid emails and every paid plan are not in that room. attachments_limit: the files of this month (UTC) would go over the volume of your plan; nothing was counted or sent. |
| 503 | unsubscribe_not_set_up | A message with "unsubscribe": true was sent while the links are not set up on our side. Write to us. |
| 400 | invalid_idempotency_key | The Idempotency-Key header is empty, longer than 255 characters, or has characters that are not printable ASCII. |
| 409 | idempotency_in_progress | The first request with this Idempotency-Key is still running. Wait a second (Retry-After: 1) and send the same request again. |
| 422 | idempotency_key_reused | This Idempotency-Key was already used in the last 24 hours with a different body. Use a new key for a different message. |
| 502 | send_failed | The email provider refused or failed. Retrying later is reasonable; a domain that is not verified yet also causes it. |
| 504 | send_unconfirmed | The email provider may have accepted the message and the answer was lost (a timeout, or a failure on its side). Do not send it again without checking the recipient: a retry can deliver it twice. Nothing is given back to your plan. The same Idempotency-Key answers the same 504 and sends nothing. |
| 500 | internal | Our side. Retry later. |
Retry only 429 (after the limit resets), 500 and 502. Never retry a 4xx as it is.
#Sending domains
You send from your own domain, so your mail carries your name and your reputation stays yours. In the dashboard, add a domain; we show three CNAME records for the DNS of that domain (DNS only, not proxied). When they are found the domain is Verified, which can take a few minutes up to a day.
Once a domain is verified you can send from any address at it, for example [email protected]. Your plan sets how many domains you can add.
#The dashboard
Sign in to manage your account. The menu on the left opens one section at a time. The address remembers which one (for example #history), so a link or a reload brings you back to the same place.
| Section | What it is for |
|---|---|
| Overview | Your plan and until when it is paid, the emails sent today and this month against your limits, and a first request to copy. |
| Plan | The plans, and paying or renewing. |
| Domains | Add the domains you send from and see the DNS records to add. |
| API keys | Replace the main key, and add or remove keys for your applications. |
| History | The messages you sent and what became of each recipient. |
| Webhook | Where we tell your server what happened to each message. |
| Templates | Keep the subject and body of an email with {{variables}}, preview it and send a test. |
| Send test | Write and send a real email from the dashboard. |
| Security | Turn on two-step sign-in with an authenticator app. |
If you forget your password, choose “Forgot your password?” on the sign-in page: we email a code to your address, and you choose a new password with it.
#Two-step sign-in
Two-step sign-in protects your account even if somebody learns your password: after the password, you enter a 6-digit code that only your phone can show. It is your choice; nobody is asked for a code until they turn it on.
- Open the Security section and choose “Turn on with an authenticator app”. Install an authenticator app on your phone (Google Authenticator, Authy, Microsoft Authenticator, 1Password …), scan the QR code, enter the code the app shows, and it is on. If you cannot scan, type the key shown under the code.
- Save that key in a password manager as well: it is shown once, and with it you can set up a new phone yourself.
- When you turn it on, you get 10 recovery codes, shown once. Save them where you keep your passwords (a password manager or a printout), not on the phone with the app. Each code works once.
- From then on, signing in asks for the password and then the code of the app. The code changes every 30 seconds, so the phone’s clock must be right (automatic time).
- To change phone, choose “Use a new phone” in Security and scan the new code. To stop using it, choose “Turn off”.
- Lost the phone? On the sign-in page choose “Use a recovery code” and enter your email, your password and one recovery code. Two-step sign-in is then switched off and you are signed in; turn it on again in Security and you get new codes. The codes that were left stop working.
- Lost the phone and no recovery code left? Write to [email protected] from the address of your account: we switch two-step sign-in off for you, and you can sign in and set it up again.
- In Security you can see how many codes are left and make new ones; new codes replace the old ones. Five wrong tries for an address are followed by a wait of up to 15 minutes.
A code by email as the second step is not offered. Two-step sign-in applies to signing in on the website, not to the API: your API key is not affected.
#API keys
Your plan sets how many keys you can have at the same time, the main key included. Use one key for each application or environment, so that you can remove one without touching the others.
| Plan | API keys |
|---|---|
| Starter | 3 |
| Basic | 5 |
| Pro | 10 |
| Free plan | 1 |
- The main key can be replaced, not removed. The old one stops working at once.
- In the API keys section, “Add a key” asks for a name (for example production or staging) and shows the key once. An added key can be removed at any time, and it stops working at once.
- Every key of an account sends as the same account: the same domains, the same limits and the same counters. Two keys do not give you two sets of limits.
- A key is never shown again. If you lost it, replace it or add another.
#Sending history
The History section lists the messages you sent, the newest first: when, from which address, to whom (to, cc and bcc) and what became of each recipient. It is for people who ask “did my email arrive?”. To react in your own program, use a webhook.
| Status | Meaning |
|---|---|
| Accepted | We handed the message to our email provider. Nothing more is known yet. |
| Delivered | The recipient’s mail server took the message. |
| Bounced | The message could not be delivered. A permanent bounce also puts the address on your suppression list. |
| Complaint | The recipient marked the message as spam. The address goes on your suppression list. |
| Rejected | Our email provider refused the message. |
| Plan | Days the history is kept |
|---|---|
| Starter | 7 |
| Basic | 14 |
| Pro | 30 |
| Free plan | 1 |
- We keep the time, the sender address, the recipient addresses, the message id and the status of each recipient. For a message sent with tracking, we also keep whether and when it was first opened and a link of it first followed (marked apart when it looks like a program did it), shown as Opened and Clicked; never the link or the device. We never keep the subject, the body or the headers.
- After the days of your plan an entry is deleted by itself, and the dashboard shows only those days.
- You can filter by status and search an address, a sender or a message id. The filter works on the messages already loaded on the page; choose “Load older messages” to go further back.
- Messages sent before the history existed are not in it. A paused account can still look at its history.
#Send a test email
The Send test section writes a real email and sends it as your account, the same way your application would: From, To, Cc, Bcc, Reply-To, the subject, a text body, an optional HTML body and extra headers (one per line, as Name: value). Under the form you see the same message as a curl command, so what works there can be copied into your code.
- It is a real email. It counts against your plan, one email for each recipient, and it appears in your history.
- From must be an address on a verified domain of your account. To, Cc and Bcc together take up to 10 different addresses; separate them with a comma or a space.
- It follows the same rules as the API: the limits, the suppression list and the errors are the same, and the answer says in words what to fix.
- To see what a test becomes, send it to your own address and open the History section: the status changes from Accepted to Delivered within seconds.
#Paying, renewing and ending
A new account starts on the Free plan, with no card, and keeps sending with less than a paid plan (see “Limits and plans”). For more, choose a plan in the Plan section: “Upgrade” for a higher plan, “Renew” for the plan you have.
- You pay in rupiah, online: QRIS, virtual account or e-wallet, through Mayar. We never see or keep a card number.
- The plan switches on by itself within a minute of the payment, and we email you a confirmation with the plan, the amount, the date until which it is paid and the reference.
- A plan lasts 30 days from the payment. Paying the same plan again before it ends adds 30 days to the end date. You can also pay for a year at once: it costs ten months, so two months are free, and it lasts 365 days. Nothing is renewed by itself.
- Paying for another plan while your paid days have not run out does not lose them. The days you have left are worth what you paid for them, and that value is added to the 30 days of the new plan as the days it buys there: on a higher plan they become fewer days, on a cheaper plan more. You pay the normal price of the new plan, its limits apply at once, and the Plan section and the confirmation email say how many days were added. For example, with 18 days left on Basic, paying for Pro gives its 30 days and about 6 more.
- Three days after the end date, sending stops (the API answers 402 plan_expired) until you pay again. Nothing is renewed by itself and nothing is charged without you asking. Your domains and keys stay.
Questions about a payment? Write to [email protected] with the reference (NX-…) shown in the Plan section.
#Running out of emails: top-ups
The emails of your plan are counted per UTC month. When 80% are used we write to the people who sign in to the account, and again at 100%. If you need more before the 1st, the Plan section of the dashboard offers two ways:
- A top-up: more emails for the rest of this month, bought once, without changing plan. On sale: +10,000 emails for Rp89.000 and +50,000 emails for Rp399.000. You can buy more than one; they add up.
- A higher plan, which also keeps the days you have left (see “Paying, renewing and ending”).
Prepaid emails, for when you have no plan: 5,000 emails for Rp49.000, bought once from the dashboard. When your plan has ended, the API sends on this balance instead of answering 402 (on the Free plan it sends on it past the 100 a day): each recipient takes one email from it, with the features of Starter (its files, 7 days of history, 10 templates, 2 domains and 3 keys, and no limit for the day), and it is not part of the shared room of the Free accounts. The balance does not reset each month and is good for 6 months from your latest purchase; buying again adds to what is left and moves the end of the whole balance 6 months on. What is unused at the end is gone and is not refunded. An account with a running paid plan does not spend it: it waits. We write to you 30 and 7 days before it ends.
How a top-up works:
- It is for an account on a paid plan that is running. The Free plan, a plan that has ended and an account without a plan buy a plan first.
- You pay as for a plan (QRIS, virtual account or e-wallet). Within a minute of the payment the emails are added to this month’s limit, and we email you a confirmation with the number of emails, the amount and the reference.
- They are added on top of the limit of your plan, and the emails you have already sent this month stay counted. If the month ended while you were paying, they count for the month in which the payment was applied.
- They can be sent until the last day of the month (UTC). What is left after that is gone: it does not carry over, and the limit of your plan alone applies from the 1st. Look at the date before you pay, especially in the last days of a month.
- It changes nothing else: not your plan, the date until which it is paid, your domains, keys or requests a second. The daily limit is not affected either (only the Free plan has one).
A top-up costs more for each email than a plan does, so if you need more every month, a higher plan is cheaper. Questions? Write to [email protected] with the reference (NX-…) shown in the Plan section.
#Limits and plans
What limits what you send is the number of emails per UTC month of your plan, and how fast you send: a few requests a second, enough for the sign-in codes and receipts of an application. A paid plan has no limit per day. When a limit is reached the API answers 429 and sends nothing; nothing is billed beyond the plan. The dashboard shows what you used.
| Plan | Emails a month | Sending domains | API keys |
|---|---|---|---|
| Starter | 10,000 | 2 | 3 |
| Basic | 50,000 | 5 | 5 |
| Pro | 130,000 | 10 | 10 |
| Free plan | no monthly limit, 100 a day | 1 | 1 |
| Plan | Requests a second, on average | At once (a burst of 5 seconds) |
|---|---|---|
| Starter | 2 | 10 |
| Basic | 5 | 25 |
| Pro | 10 | 50 |
| Free plan | 1 | 5 |
That is how many requests we accept from your account: the rate is the average, and in any window of 5 seconds you may send that many times 5 requests, all in the first second if you like. So the sign-in codes of a few people who register at the same moment are accepted together; what you send past the burst has to wait for the next window. All customers also share the capacity of the service: when it is full for a moment, the answer is 429 busy for whoever asks next, and nothing is counted.
A request is one message. Each recipient of it (in to, cc and bcc; an address written twice counts once) counts as one email of your plan.
If you go over, the answer says which limit it was (429 rate_limited, busy, monthly_limit or daily_limit) and the request is neither sent nor counted. For rate_limited, wait the seconds the Retry-After header says (up to 5) and send again; for busy, wait a second. Add a little random delay so that many retries do not arrive together. For monthly_limit, buy a top-up or move to a larger plan (see “Running out of emails”), or wait for the next month; retrying earlier cannot work. One request may carry up to 10 recipients (in to, cc and bcc) of the same message: it is one request for the rate, and one email of your plan for each recipient. If you send more than the burst at once, queue on your side and spread the requests out.
A new account starts on the Free plan, with no card: up to 100 emails a day, one domain, 1 day of history, 3 templates and no files. The Free accounts share a room of emails for each UTC day, so on a day it is full they are answered 429 free_capacity until 00:00 UTC. A plan is paid in rupiah, online (QRIS, virtual account or e-wallet), switched on when the payment arrives, and lasts 30 days. See “Paying, renewing and ending”. Three days after it ends, the API answers 402 plan_expired until you pay again; nothing is renewed by itself.
#Bounces, complaints and the suppression list
When a message bounces for good (the address does not exist), draws a complaint, or is rejected, the address goes on your suppression list. The next send to it is answered 422 recipient_suppressed. Each account has its own list: another customer’s bounce never blocks your mail. Temporary problems, such as a full mailbox, do not suppress an address.
On Basic and Pro the Suppression section of the dashboard shows the list: the address, why it is there, when it went on and when it leaves. You can search it by part of an address and delete an address, which also removes it from the list our email provider keeps for your account, so you can mail it again. Pro can also add an address by hand and choose after how many days an address that went on by itself leaves the list (30, 90, 180 or 365 days, or never). An address you add by hand stays until you delete it.
| Plan | Manage the list | Add by hand | Automatic removal |
|---|---|---|---|
| Starter | No | No | Never |
| Basic | Search, delete | No | Automatic |
| Pro | Search, delete | Yes | Your choice |
The address itself is kept only for the plans that manage the list, and only while it is on the list; on Starter only a one-way hash is kept. Removing an address that still does not work just lets it bounce again, which counts against your reputation.
Keep your bounce and complaint rates low. Too many can pause your sending; a pause affects your account only.
#Checking your domain’s reputation
There are two different things to look at. The first is how your own sending is doing: how many messages bounced or drew a complaint. The second is how the big mailbox providers (Gmail, Outlook) see your domain. We can show the first one. The second one only the providers can tell you, and their tools are free.
Sending health. In the dashboard, under History, the “Sending health” panel counts what became of your messages in the days your plan keeps: how many were delivered, bounced, drew a complaint or were rejected, and the share of bounces and complaints. It says Good, Watch or High. It needs at least 50 recipients with an answer before it says anything.
| Measure | Watch from | Email providers act from |
|---|---|---|
| Bounces | 2 % | 5 % |
| Complaints | 0.05 % | 0.1 % |
Above the right-hand column, our email provider reviews the account and can pause sending, and Gmail and Outlook start to put your mail in the spam folder. To bring the numbers down, send only to addresses people gave you, check that they are typed correctly, and stop sending to addresses that bounce (they are put on your suppression list for you).
Gmail. Google Postmaster Tools shows how Gmail sees your domain (reputation, spam rate, authentication). It is free: sign in at postmaster.google.com with a Google account, add your sending domain, and add the TXT record Google gives you at your DNS provider. Google shows data only when your domain sends enough mail to Gmail addresses, so a small sender may see nothing. We cannot see it for you: it belongs to whoever proves they own the domain.
Outlook and Hotmail. Microsoft’s tools work by IP address, and the IP addresses belong to our email provider and are shared, so they say nothing about your domain. For Outlook, the bounces in your sending health are the best sign.
DMARC. Your domain is signed with DKIM (the records you added when you set up the domain), which is what DMARC needs to pass. Publishing a DMARC record tells mailbox providers what to do with mail that fails, and sends you reports of who is sending as your domain. Start by only watching:
_dmarc.your-domain.com TXT "v=DMARC1; p=none; rua=mailto:[email protected]"After a few weeks of reports, if only your own mail shows up, you can move p=none to p=quarantine. Use an address you read for the reports; they are sent by the mailbox providers, not by us.
#Webhooks
A webhook is a call from NexDeliv to your server when something happens to an email you sent. Use it to learn that a message was delivered, to stop mailing an address that bounced, or to react to a complaint.
Set it up in the dashboard, under Webhook: enter an https address on your server and save. The first time, a signing secret (whsec_…) is shown once; store it as a secret. “Send a test event” checks that your server answers.
| Event | When | data |
|---|---|---|
| email.delivered | The recipient’s mail server accepted the message. | messageId, to |
| email.bounced | The message bounced. “Permanent” means the address does not work and is now suppressed; “Transient” means it might work next time. | messageId, to, bounce.type, bounce.subType |
| email.complained | The recipient marked the message as spam. | messageId, to, complaint.type (when given) |
| email.rejected | We refused to send it (for example it contained a virus). | messageId, to, reject.reason |
| email.unsubscribed | Someone followed the unsubscribe link of a message. Only sent when you choose it, under Webhook in the dashboard. | messageId (empty), to |
| email.opened | A message sent with tracking was opened. Only sent when you choose it. Many mail programs load images for the person (and hide when), so an open is a hint, not a count. | messageId, to, open.bot |
| email.clicked | A link of a message sent with tracking was followed. Only sent when you choose it. Mail scanners follow every link: bot says when we think a program did. | messageId, to, click.link, click.bot |
Each call is a POST with a JSON body in this shape, and these headers: NexDeliv-Event (the type), NexDeliv-Event-Id (the id) and NexDeliv-Signature.
POST /webhooks/nexdeliv HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: NexDeliv-Webhooks/1
NexDeliv-Event: email.bounced
NexDeliv-Event-Id: evt_9f2c1d7a4b6e8f0a1b2c3d4e
NexDeliv-Signature: t=1761559202,v1=5b1f0c6a8e...
{
"id": "evt_9f2c1d7a4b6e8f0a1b2c3d4e",
"type": "email.bounced",
"createdAt": "2026-10-27T10:00:02.000Z",
"data": {
"messageId": "010e01a17b5ad26e-10055656-9b2f-4b83-8369-48e48a22c42c-000000",
"to": "[email protected]",
"bounce": { "type": "Permanent", "subType": "NoEmail" }
}
}// email.delivered: no extra field
{ "id": "evt_...", "type": "email.delivered", "createdAt": "...", "data": { "messageId": "...", "to": "[email protected]" } }
// email.complained: "type" is what the mailbox provider reported ("abuse", "fraud", ...); it can be missing
{ "id": "evt_...", "type": "email.complained", "createdAt": "...", "data": { "messageId": "...", "to": "[email protected]", "complaint": { "type": "abuse" } } }
// email.rejected: we refused to send it
{ "id": "evt_...", "type": "email.rejected", "createdAt": "...", "data": { "messageId": "...", "to": "[email protected]", "reject": { "reason": "Bad content" } } }// email.unsubscribed: only for a webhook that chose it. messageId is empty: the person followed a link, not a message.
{ "id": "evt_...", "type": "email.unsubscribed", "createdAt": "2026-10-28T10:00:00Z", "data": { "messageId": "", "to": "[email protected]" } }// email.opened: "bot" is true when we think a program opened it (a preview, a mail scanner), not a person
{ "id": "evt_...", "type": "email.opened", "createdAt": "2026-10-28T10:05:00Z", "data": { "messageId": "...", "to": "[email protected]", "open": { "bot": false } } }
// email.clicked: "link" is the address you wrote in the message, not ours
{ "id": "evt_...", "type": "email.clicked", "createdAt": "2026-10-28T10:06:00Z", "data": { "messageId": "...", "to": "[email protected]", "click": { "link": "https://your-domain.com/receipts/123", "bot": false } } }createdAt is when the event happened. The body never contains the subject or the content of your message.
#Verify the signature
Anyone who knows your address could post to it, so check every call. NexDeliv-Signature looks like t=1761559202,v1=<hex>. Compute HMAC-SHA256 over the text “<t>.<raw body>” with your secret as the key, write it in hex, and compare it with v1 in constant time. Refuse a call whose t is more than five minutes from your clock, so an old call cannot be replayed.
Use the raw body exactly as it arrived: parsing the JSON and writing it again changes the bytes and the signature fails.
import crypto from "node:crypto";
// rawBody: the body exactly as it arrived (a string or Buffer), NOT re-serialised JSON.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const { t, v1 } = parts;
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// Express: app.post("/webhooks/nexdeliv", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(req.body.toString(), req.get("NexDeliv-Signature") ?? "", process.env.NEXDELIV_WEBHOOK_SECRET)) return res.sendStatus(400);
// const event = JSON.parse(req.body.toString());
// ...handle it, keeping event.id so a repeat is ignored...
// res.sendStatus(200);
// });#Answering, retries and repeats
- Answer with any 2xx status as soon as you have the event, within a few seconds; do the work afterwards. We wait up to about ten seconds.
- If your server cannot be reached, is too slow, or answers 408, 429 or 5xx, we try again a few minutes later, up to three attempts in all. Any other answer (such as 400 or 404) is final: we do not retry it.
- Delivery is at least once, so the same event can arrive more than once. Keep event ids you have handled and ignore repeats.
- Events may arrive out of order. A bounce can come before the delivery event of an earlier message.
- We do not follow redirects. The address must be https on port 443 and name a public server (not an IP address, and not a name that leads to a private network).
- The dashboard shows the status of the last call. Create a new secret there if the old one leaked: the old one stops working at once.
// A good handler: verify, ignore what it has seen, answer fast, work later.
const seen = new Set(); // in production: a table or cache with an expiry
app.post("/webhooks/nexdeliv", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString();
if (!verify(raw, req.get("NexDeliv-Signature") ?? "", process.env.NEXDELIV_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(raw);
if (!seen.has(event.id)) {
seen.add(event.id);
if (event.type === "email.bounced" && event.data.bounce.type === "Permanent") markAddressInvalid(event.data.to);
}
res.sendStatus(200);
});Calls that did not work. If your server still does not answer with a 2xx after our tries, we keep that event for 7 days. In the dashboard, under Webhook, “Calls that did not work” lists them with the state, the tries and the last answer, and a Send again button sends one with the same event id (use the webhook as it is now, so a new address or secret counts). The same is available to the dashboard as GET /v1/account/webhook/events and POST /v1/account/webhook/events/{ref}/replay. The kept event holds the address of the person it is about, and goes after 7 days.
Webhooks are a convenience, not a guarantee: events that were never kept (they worked at the first try) or are older than 7 days cannot be sent again. Your suppression list in NexDeliv stays correct either way.