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