curl Examples

Every request goes to https://emails.routeon.io/api/v1 and carries Authorization: Bearer <API_KEY>. Create a key in the customer portal at https://emails.routeon.io under "API keys", then set it once in your shell. The examples pipe responses through jq; install it or drop | jq to see raw JSON.

export API_KEY="<API_KEY>"
export BASE_URL="https://emails.routeon.io/api/v1"

Every response uses the same envelope. Success: {"status":"success","http_code":202,"payload":{...},"meta":{}}. Error: status is "error", payload is [], and the cause is in meta.reason, meta.details.code and meta.validation_error[]. See Errors.

Check your API key

curl -sS "$BASE_URL/me" -H "Authorization: Bearer $API_KEY" | jq
{
  "status": "success",
  "http_code": 200,
  "payload": {
    "tenant_id": "6f1c2a9e-4d2b-4b8a-9c3e-2f7d5a1b8e40",
    "scopes": ["email:send", "otp:send", "otp:verify", "messages:read", "campaigns:write", "webhooks:write"],
    "auth_type": "api_key"
  },
  "meta": {}
}

scopes lists what this key may do. A call outside those scopes answers 403 FORBIDDEN with no meta.details.code.

Send a transactional email

The template must be active and of class transactional. tags and metadata are stored on the message and returned by message lookups and webhooks.

curl -sS -X POST "$BASE_URL/transactional/send" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -H "Idempotency-Key: order-1042-confirmation" 
  -d '{
    "domain_id": "<DOMAIN_ID>",
    "to": "jane.doe@example.com",
    "template_id": "<TRANSACTIONAL_TEMPLATE_ID>",
    "variables": { "first_name": "Jane", "order_number": "1042" },
    "tags": ["order-confirmation", "shop:eu"],
    "metadata": { "order_id": "1042", "customer_tier": "gold" }
  }' | jq
{
  "status": "success",
  "http_code": 202,
  "payload": { "message_id": "01923f6e-7b1a-7c3d-9e4f-5a6b7c8d9e0f", "send_at": null },
  "meta": {}
}

Instead of domain_id you can pass "from": "Shop <orders@yourdomain.com>"; its domain must be one of your verified domains. Re-sending the same request with the same Idempotency-Key within 24 hours returns the same message_id and sends nothing.

To schedule the send, add "send_at": "<FUTURE_ISO8601_TIMESTAMP>" (an explicit UTC offset, at most 30 days ahead). A scheduled message can be canceled while it is still waiting:

curl -sS -X DELETE "$BASE_URL/transactional/<MESSAGE_ID>" 
  -H "Authorization: Bearer $API_KEY" | jq
{
  "status": "success",
  "http_code": 200,
  "payload": { "message_id": "<MESSAGE_ID>", "status": "canceled" },
  "meta": {}
}

Once the message has been picked up for delivery, cancel answers 409 with meta.details.code = "message_not_scheduled".

Send a batch of transactional emails

Up to 100 recipients share one template; tags and metadata apply to the whole batch.

curl -sS -X POST "$BASE_URL/transactional/send-batch" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -H "Idempotency-Key: shipping-notices-batch-77" 
  -d '{
    "domain_id": "<DOMAIN_ID>",
    "template_id": "<TRANSACTIONAL_TEMPLATE_ID>",
    "tags": ["shipping-notice"],
    "items": [
      { "to": "jane.doe@example.com", "variables": { "first_name": "Jane" } },
      { "to": "john.smith@example.com", "variables": { "first_name": "John" } }
    ]
  }' | jq
{
  "status": "success",
  "http_code": 202,
  "payload": {
    "results": [
      { "index": 0, "status": "queued", "message_id": "01923f6e-8c2b-7d4e-8f5a-6b7c8d9e0f1a" },
      { "index": 1, "status": "failed", "code": "recipient_suppressed" }
    ]
  },
  "meta": {}
}

The batch answers 202 even when items fail. Match results to your input by index, and resend only the failed recipients under a new Idempotency-Key: replaying the old key returns the same per-item failures.

Look up messages

One message with its event history:

curl -sS "$BASE_URL/messages/<MESSAGE_ID>" -H "Authorization: Bearer $API_KEY" | jq
{
  "status": "success",
  "http_code": 200,
  "payload": {
    "id": "<MESSAGE_ID>",
    "campaign_id": null,
    "to_email": "jane.doe@example.com",
    "status": "delivered",
    "template_id": "<TRANSACTIONAL_TEMPLATE_ID>",
    "source_type": "transactional",
    "tags": ["order-confirmation", "shop:eu"],
    "metadata": { "customer_tier": "gold", "order_id": "1042" },
    "events": [{ "type": "delivery", "at": "<ISO8601_TIMESTAMP>" }]
  },
  "meta": {}
}

The response has more fields than shown here, such as error_message, send_at and created_at. Event types (delivery, bounce, …) are spelled differently from message statuses (delivered, bounced, …).

Messages carrying a tag (the value is lower-cased before matching):

curl -sS -G "$BASE_URL/messages" -H "Authorization: Bearer $API_KEY" 
  --data-urlencode "tag=order-confirmation" 
  --data-urlencode "per_page=50" | jq '.payload[] | {id, to_email, status}'

Other filters: status, email, campaign_id, from/to (creation time range), and transactional=true for messages that belong to no campaign.

Request and verify an OTP code

OTP needs a template of class critical.

curl -sS -X POST "$BASE_URL/otp/request" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -d '{
    "domain_id": "<DOMAIN_ID>",
    "to": "jane.doe@example.com",
    "template_id": "<OTP_TEMPLATE_ID>",
    "purpose": "login",
    "code_length": 6,
    "ttl_seconds": 300
  }' | jq
{
  "status": "success",
  "http_code": 202,
  "payload": { "otp_id": "01923f70-1a2b-7c3d-8e4f-9a0b1c2d3e4f", "expires_at": "<ISO8601_TIMESTAMP>" },
  "meta": {}
}

Verify what the user typed, by otp_id (or by to plus purpose, never both selectors):

curl -sS -X POST "$BASE_URL/otp/verify" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -d '{ "otp_id": "<OTP_ID>", "code": "<CODE_FROM_USER>" }' | jq
{
  "status": "success",
  "http_code": 200,
  "payload": { "verified": false, "attempts_remaining": 4 },
  "meta": {}
}

A wrong code answers 200 with verified: false. A code that can no longer succeed answers 410 with meta.details.code: otp_not_found or otp_expired (request a new code), otp_already_used, or otp_attempts_exceeded (the fifth wrong attempt used up the code).

Create and send a campaign

The template must be active and of class marketing. The sender comes from sender_profile_id; omit it to use the sender profile routed for campaigns, or your default one.

curl -sS -X POST "$BASE_URL/campaigns" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -H "Idempotency-Key: campaign-create-product-update" 
  -d '{
    "name": "Product update",
    "type": "blast",
    "domain_id": "<DOMAIN_ID>",
    "template_id": "<MARKETING_TEMPLATE_ID>",
    "sender_profile_id": "<SENDER_PROFILE_ID>",
    "audience": { "include": [{ "kind": "list", "id": "<LIST_ID>" }] }
  }' | jq '.payload | {id, status}'

The campaign is created as draft. Launch it:

curl -sS -X POST "$BASE_URL/campaigns/<CAMPAIGN_ID>/send" 
  -H "Authorization: Bearer $API_KEY" | jq '.http_code, (.payload.status // .meta)'

A successful launch answers 202 with the campaign in sending. If some recipients would get more than one email that day, it answers 409 with a conflict summary (no code) in meta.details. Choose how to handle them and launch again:

curl -sS -X POST "$BASE_URL/campaigns/<CAMPAIGN_ID>/send" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -d '{ "conflict_resolution": "exclude_conflicts" }' | jq '.http_code, .payload.status'

The other choices are send_anyway, delay_1_day and spread_schedule. A 409 that carries meta.details.code is a different refusal (invalid_state, campaign_needs_content_review, …); see Errors.

Create a webhook and verify its signature

curl -sS -X POST "$BASE_URL/webhooks" 
  -H "Authorization: Bearer $API_KEY" 
  -H "Content-Type: application/json" 
  -d '{
    "url": "https://hooks.example.com/routeon",
    "events": ["email.delivered", "email.bounced", "email.complained", "contact.unsubscribed"],
    "description": "Delivery status sync"
  }' | jq '.payload | {id, status, secret}'

payload.secret is returned only in this response. Store it in your secret store.

Each delivery is a POST with X-Timestamp (Unix seconds) and X-Signature, the hex HMAC-SHA256 of <X-Timestamp>.<raw body> keyed with the secret. The body is {"id", "type", "occurred_at", "data"}; see Webhooks for each event’s data. This script checks a delivery you saved to a file:

#!/usr/bin/env bash
# usage: verify-webhook.sh BODY_FILE X_SIGNATURE X_TIMESTAMP
WEBHOOK_SECRET="<WEBHOOK_SECRET>"
BODY_FILE="$1"; RECEIVED="$2"; TIMESTAMP="$3"

now=$(date +%s)
if [ $(( now > TIMESTAMP ? now - TIMESTAMP : TIMESTAMP - now )) -gt 300 ]; then
  echo "stale timestamp" >&2; exit 1
fi

expected=$({ printf '%s.' "$TIMESTAMP"; cat "$BODY_FILE"; } 
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')

if [ "$expected" = "$RECEIVED" ]; then echo "valid"; else echo "invalid" >&2; exit 1; fi

Sign the exact bytes you received, not re-serialized JSON. In a service, compare with a constant-time function (see the TypeScript and Python examples) and answer 2xx within 10 seconds.

Handle errors and rate limits

This helper sends one request, retries only when the request-rate limiter answered 429 with meta.details.code = "rate_limited", and waits for the Retry-After it read from that same response (-D). A daily cap (otp_recipient_daily_cap, transactional_daily_limit_exceeded) also answers 429 but does not clear for hours, so it is returned instead of retried. The retry reuses the same Idempotency-Key; that is safe because a rate-limited request never ran.

# usage: api METHOD PATH [JSON_BODY] [IDEMPOTENCY_KEY]
# Prints the response body. Returns 0 on 2xx, 1 otherwise.
api() {
  local method="$1" path="$2" body="${3:-}" idem="${4:-}"
  local headers body_file status attempt=1 max_attempts=5 code wait
  headers=$(mktemp); body_file=$(mktemp)
  while :; do
    local args=(-sS -X "$method" "$BASE_URL$path" -H "Authorization: Bearer $API_KEY"
                -D "$headers" -o "$body_file" -w '%{http_code}')
    [ -n "$body" ] && args+=(-H "Content-Type: application/json" --data "$body")
    [ -n "$idem" ] && args+=(-H "Idempotency-Key: $idem")
    status=$(curl "${args[@]}")
    code=$(jq -r '.meta.details.code // empty' "$body_file" 2>/dev/null)
    if [ "$status" = "429" ] && [ "$code" = "rate_limited" ] && [ "$attempt" -lt "$max_attempts" ]; then
      wait=$(awk 'tolower($1) == "retry-after:" { print $2 }' "$headers" | tr -d 'r')
      sleep "${wait:-1}"
      attempt=$((attempt + 1))
      continue
    fi
    cat "$body_file"
    rm -f "$headers" "$body_file"
    [ "${status:0:1}" = "2" ]
    return
  done
}

if ! out=$(api POST /otp/request '{"domain_id":"<DOMAIN_ID>","to":"jane.doe@example.com","template_id":"<OTP_TEMPLATE_ID>","purpose":"login"}' "otp-login-jane-1"); then
  echo "$out" | jq '{reason: .meta.reason, code: .meta.details.code, fields: .meta.validation_error}' >&2
fi

Branch on meta.details.code when it is present, then on meta.reason. Codes such as send_at_too_far or template_not_active arrive in meta.validation_error[].message instead; Errors says where each code appears.

Page through a list

List endpoints return up to per_page items and a meta.cursor while more pages exist. Pass it back until it is absent:

cursor=""
while :; do
  path="/messages?tag=order-confirmation&per_page=200"
  [ -n "$cursor" ] && path="$path&cursor=$(jq -rn --arg c "$cursor" '$c | @uri')"
  page=$(api GET "$path") || { echo "$page" >&2; break; }
  echo "$page" | jq -c '.payload[] | {id, to_email, status}'
  cursor=$(echo "$page" | jq -r '.meta.cursor // empty')
  [ -z "$cursor" ] && break
done