Getting started
Errors
The error response format, every error name and HTTP status Sooner returns, and how to handle and retry them.
Sooner uses standard HTTP status codes. Every error response has the same JSON body, with the same shape and error names as Resend, so existing error handling keeps working.
{
"statusCode": 422,
"message": "`to`: Invalid input",
"name": "validation_error"
}| Field | Description |
|---|---|
statusCode | The HTTP status code, repeated in the body. |
message | A human-readable explanation. Do not parse it. For validation errors it starts with the field path in backticks when one applies. |
name | A stable, machine-readable error name. Branch on this. |
Error names
| Name | Status | When it happens |
|---|---|---|
missing_api_key | 401 | The Authorization: Bearer header is missing. |
invalid_api_key | 403 | The key does not start with sn_, is unknown, or has been revoked. |
restricted_api_key | 401 | A sending_access key called an endpoint that needs full_access, or a key locked to one domain sent from another domain. |
validation_error | 422 | The body is not valid JSON, a field is invalid, a limit was exceeded, or the operation is not allowed in the resource's current state. |
missing_required_field | 422 | A required value is missing, such as from or subject after a template is applied, or segment_id when sending a broadcast. |
invalid_from_address | 422 | The from address is invalid, or its domain is not verified in the key's project. |
invalid_idempotency_key | 400 | The Idempotency-Key header is not 1 to 256 characters. |
invalid_idempotent_request | 409 | The same Idempotency-Key was used with a different request body. |
not_found | 404 | The resource or route does not exist in this project. IDs from other projects return this too. |
rate_limit_exceeded | 429 | Too many requests for your plan. Honor the retry-after header. |
daily_quota_exceeded | 429 | The Free plan's daily sending cap was reached. Honor the retry-after header. |
monthly_quota_exceeded | 429 | The Free plan's monthly sending quota was reached. |
security_error | 451 | The team is suspended. Contact support. |
internal_server_error | 500 | Something went wrong on our side. |
Match on name, and fall back to the HTTP status for any name you do not recognize.
Validation errors
Request bodies are validated before anything is queued. In a batch, a single invalid email rejects the whole request and the message includes the position, for example (email 2), counting from 0. Nothing is sent and no quota is used when a request is rejected.
curl -X POST https://api.sooner.dev/emails \
-H "Authorization: Bearer $SOONER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "Acme <hello@acme.com>", "to": "you@example.com", "subject": "Hi" }'{
"statusCode": 422,
"message": "`html`: Provide `html`, `text` or `template`. (`react` must be rendered to html by your SDK.)",
"name": "validation_error"
}Rate limit and quota errors
rate_limit_exceeded, daily_quota_exceeded and monthly_quota_exceeded all use status 429. Check the name to tell them apart, because they need different handling:
rate_limit_exceeded: wait for theretry-afterheader (in seconds) and retry.daily_quota_exceeded: the cap resets at 00:00 UTC.retry-aftertells you how many seconds remain.monthly_quota_exceeded: sending resumes on the first day of the next month (UTC), or when you upgrade. There is noretry-afterheader.
See Rate limits.
What to retry
| Response | Retry? |
|---|---|
| 400, 401, 403, 404, 409, 422, 451 | No. Fix the request, key or resource first. |
429 rate_limit_exceeded or daily_quota_exceeded | Yes, after the retry-after delay. |
429 monthly_quota_exceeded | No, until the quota resets or you upgrade. |
| 500, or a network error or timeout | Yes, with exponential backoff. |
Send an Idempotency-Key header on every send so that retrying a request that may have succeeded can never produce a duplicate email. See Idempotency.
async function sendWithRetry(body: unknown, key: string, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fetch("https://api.sooner.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SOONER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
if (res.ok) return res.json();
const err = await res.json();
const retryable = res.status >= 500 || (res.status === 429 && err.name !== "monthly_quota_exceeded");
if (!retryable || i === attempts - 1) throw new Error(`${err.name}: ${err.message}`);
const wait = Number(res.headers.get("retry-after")) || 2 ** i;
await new Promise((r) => setTimeout(r, wait * 1000));
}
}