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.

JSON
{
  "statusCode": 422,
  "message": "`to`: Invalid input",
  "name": "validation_error"
}
FieldDescription
statusCodeThe HTTP status code, repeated in the body.
messageA human-readable explanation. Do not parse it. For validation errors it starts with the field path in backticks when one applies.
nameA stable, machine-readable error name. Branch on this.

Error names

NameStatusWhen it happens
missing_api_key401The Authorization: Bearer header is missing.
invalid_api_key403The key does not start with sn_, is unknown, or has been revoked.
restricted_api_key401A sending_access key called an endpoint that needs full_access, or a key locked to one domain sent from another domain.
validation_error422The 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_field422A required value is missing, such as from or subject after a template is applied, or segment_id when sending a broadcast.
invalid_from_address422The from address is invalid, or its domain is not verified in the key's project.
invalid_idempotency_key400The Idempotency-Key header is not 1 to 256 characters.
invalid_idempotent_request409The same Idempotency-Key was used with a different request body.
not_found404The resource or route does not exist in this project. IDs from other projects return this too.
rate_limit_exceeded429Too many requests for your plan. Honor the retry-after header.
daily_quota_exceeded429The Free plan's daily sending cap was reached. Honor the retry-after header.
monthly_quota_exceeded429The Free plan's monthly sending quota was reached.
security_error451The team is suspended. Contact support.
internal_server_error500Something 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.

Shell
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" }'
JSON
{
  "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 the retry-after header (in seconds) and retry.
  • daily_quota_exceeded: the cap resets at 00:00 UTC. retry-after tells 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 no retry-after header.

See Rate limits.

What to retry

ResponseRetry?
400, 401, 403, 404, 409, 422, 451No. Fix the request, key or resource first.
429 rate_limit_exceeded or daily_quota_exceededYes, after the retry-after delay.
429 monthly_quota_exceededNo, until the quota resets or you upgrade.
500, or a network error or timeoutYes, 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.

TypeScript
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));
  }
}