Notix
Guides

Send email with curl, and know what came back.

The whole email API fits in one HTTP request, which makes curl the fastest way to see it work and the right way to read the contract before you write a client. This guide sends a real email from the terminal, reads the error envelope, and turns the call into a bash script that retries without ever sending twice.

What you need

Two things, both from the dashboard.

An API key in the environment.

Created under API keys and shown once. Export it as NOTIX_API_KEY in the shell; every example reads it from there so the key never lands in a script or a shell history line.

A verified sending domain.

The from address has to be on a domain whose records you have published. The quickstart walks through it; the SPF, DKIM and DMARC page explains what each record proves.

Steps

One request, three headers.

  1. Send.

    Authorization: Bearer carries the key, Content-Type: application/json tells the API how to read the body, and Idempotency-Key names the business event so a repeat of this exact call returns the same email instead of a second one. The body is from, to and either a subject with html or text (send both), or a templateId with variables.

    terminal
    curl -X POST https://app.usenotix.dev/api/v1/emails \
      -H "Authorization: Bearer $NOTIX_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: order-4471-receipt" \
      -d '{
        "from": "receipts@acme.com",
        "to": "customer@example.com",
        "subject": "Your receipt for order 4471",
        "html": "<p>Thanks for your order.</p>",
        "text": "Thanks for your order."
      }'
    
    # 200 { "emailId": "eml_01j9x2mz" }
    
  2. Keep the id.

    A 200 answers with the email’s id. It is how you read the message back and the data.id on every webhook event for it.

    terminal
    # Read a message back by the id the send returned.
    curl https://app.usenotix.dev/api/v1/emails/eml_01j9x2mz \
      -H "Authorization: Bearer $NOTIX_API_KEY"
    
  3. Read the error envelope.

    Every failure is one shape, { error: { code, message } }, and the status matches the code. The codes below are the complete list the API can answer with; the two you will meet first are BAD_REQUEST for an unverified from domain and UNAUTHORIZED for a key that did not make it into the header.

    status, code and meaning
    # The key is missing or wrong.
    # 401 { "error": { "code": "UNAUTHORIZED", "message": "..." } }
    
    # The from address is on a domain you have not verified, or a field is over
    # its limit. The message names the field.
    # 400 { "error": { "code": "BAD_REQUEST", "message": "..." } }
    
    # A sending-access key reached an endpoint it cannot use, or the key is
    # restricted to another domain.
    # 403 { "error": { "code": "FORBIDDEN", "message": "..." } }
    
    # The same Idempotency-Key with a different body, or the first request is
    # still in flight.
    # 409 { "error": { "code": "NOT_UNIQUE", "message": "..." } }
    
    # Too many requests. The headers say when to try again.
    # 429 { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Try again in 60 seconds." } }
    #     Retry-After: 60
    #     X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset
    
    # A one-time code refused by risk scoring (the verify endpoints only).
    # 422 { "error": { "code": "RISK_REFUSED", "message": "..." } }
    
    # A wallet team with no balance for an SMS.
    # 402 { "error": { "code": "INSUFFICIENT_BALANCE", "message": "..." } }
    
    # Over 20 MB. Refused at the edge, so there is no JSON body at all.
    # 413
    
In a script

A bash script that retries without sending twice.

Three habits turn the one-liner into something a cron job can run: set -euo pipefail so a missing variable stops the script, --fail-with-body so a 4xx exits non-zero but still shows the envelope, and one Idempotency-Key reused on every attempt so the retry loop is safe by construction.

send-receipt.sh
#!/usr/bin/env bash
# send-receipt.sh <order-id> <to>
# Sends one receipt. Safe to run twice: the same order id is the same
# Idempotency-Key, so a retry returns the original email instead of a second one.
set -euo pipefail

: "${NOTIX_API_KEY:?set NOTIX_API_KEY in the environment, never in the script}"
ORDER_ID="$1"
TO="$2"

BODY=$(cat <<JSON
{
  "from": "receipts@acme.com",
  "to": "$TO",
  "templateId": "tpl_receipt_v4",
  "variables": { "orderId": "$ORDER_ID" }
}
JSON
)

for attempt in 1 2 3; do
  # --fail-with-body: exit non-zero on 4xx/5xx but still print the error envelope.
  if RESPONSE=$(curl --silent --show-error --fail-with-body \
      -X POST https://app.usenotix.dev/api/v1/emails \
      -H "Authorization: Bearer $NOTIX_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: order-$ORDER_ID-receipt" \
      -d "$BODY"); then
    echo "queued: $RESPONSE"
    exit 0
  fi

  # Only a 429 or a 5xx is worth retrying; a 400, 401, 403 or 409 will not
  # change on the next try. Read the code out of the envelope.
  CODE=$(printf '%s' "$RESPONSE" | sed -n 's/.*"code":"\([A-Z_]*\)".*/\1/p')
  case "$CODE" in
    RATE_LIMITED|INTERNAL_SERVER_ERROR|SERVICE_UNAVAILABLE|"")
      echo "attempt $attempt failed ($CODE), retrying" >&2
      sleep $((attempt * 5))
      ;;
    *)
      echo "not retrying: $RESPONSE" >&2
      exit 1
      ;;
  esac
done

echo "gave up after 3 attempts" >&2
exit 1

Retry only what can change.

A RATE_LIMITED answer carries Retry-After in seconds and the X-RateLimit-* headers; a 5xx is transient. A 400, 401, 403 or 409 will answer the same way on the next attempt, so the script stops on those and prints the envelope for a person.

The key does the deduplication.

Because the key is order-<id>-receipt, a second run of the script for the same order, or a retry after a timeout whose request did get through, returns the original id. The idempotency page has the exact rules: 24 hours, 256 characters, 409 on a different body.

Batch

Up to 100 emails in one request.

POST /api/v1/emails/batch takes an array of the same objects. Each item carries the single-send limits; the batch as a whole is capped at 20 MB of body and 40 MB of attachments once decoded. Items can carry their own scheduledAt. Batch sends and scheduling are covered in full on the batch and scheduled sends page.

terminal
# Up to 100 emails in one call; one Idempotency-Key for the whole batch.
curl -X POST https://app.usenotix.dev/api/v1/emails/batch \
  -H "Authorization: Bearer $NOTIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: statements-2026-09" \
  -d '[
    { "from": "statements@acme.com", "to": "ada@example.com",
      "templateId": "tpl_statement", "variables": { "month": "September" } },
    { "from": "statements@acme.com", "to": "tunde@example.com",
      "templateId": "tpl_statement", "variables": { "month": "September" },
      "scheduledAt": "2026-10-01T08:00:00+01:00" }
  ]'

# 200 { "data": [ { "emailId": "eml_1" }, { "emailId": "eml_2" } ] }

For the production reasoning behind idempotency keys, the error envelope and webhooks, read the Node.js guide; the contract is the same whatever sends the request.

FAQ

Questions, answered.

How do I send an email with curl?
One POST to https://app.usenotix.dev/api/v1/emails with three headers, Authorization: Bearer <your API key>, Content-Type: application/json and an Idempotency-Key of your choosing, and a JSON body with from, to and either subject plus html or text, or a templateId plus variables. The response is 200 with { "emailId": "..." }. The from address must be on a domain you have verified in the dashboard.
How do I send email from a bash script?
Wrap the curl call in a script that reads the key from the environment (never hardcode it), builds the JSON body with a heredoc, and uses --fail-with-body so a 4xx or 5xx exits non-zero while still printing the error envelope. Retry only on RATE_LIMITED or a 5xx, and reuse the same Idempotency-Key on every attempt so a retry can never send twice. The script on this page is the complete pattern, including the case statement that decides what is worth retrying.
What does the error response look like?
Every error is a JSON object of one shape: { "error": { "code", "message" } }, with the HTTP status matching the code: 400 BAD_REQUEST, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 409 NOT_UNIQUE, 422 RISK_REFUSED, 402 INSUFFICIENT_BALANCE, 429 RATE_LIMITED, 500 INTERNAL_SERVER_ERROR, 503 SERVICE_UNAVAILABLE. The one exception is a request over 20 MB, which is refused at the edge with a 413 and no body.
Why does my curl request answer 400 with BAD_REQUEST?
Almost always the from address: its domain has to be verified in the dashboard first, with the SPF, DKIM and DMARC records Notix gives you published. The message in the envelope names the field. The other common cause is a field over its limit: a subject over 998 characters, html or text over 2,000,000 characters, or an attachment over 7 MB decoded.
What is the Idempotency-Key header for?
It makes a retry safe. Send the same key with the same body within 24 hours and the API returns the original email's id instead of sending again; the same key with a different body answers 409 NOT_UNIQUE. Use the id of the business event, the order or the invoice, so a script that runs twice or a job that is redelivered cannot produce two emails. Keys are up to 256 characters.
Can I send with an HTTP request from a language without an SDK?
Yes. The curl call on this page is the whole contract: any HTTP client that can send a JSON POST with a Bearer header works, and the response and error envelope are the same. The SDKs for TypeScript, Python, PHP and Go are thin typed wrappers over exactly these endpoints, so the field names here match theirs.

Run the first request now.

Verify a domain, copy an API key, paste the curl call. The free plan does not ask for a card.