Reference

Errors

Every failure returns the same envelope. Branch on the stable code, never on the message.

The error envelope

When a request fails, Sendara returns an HTTP status in the 4xx or 5xx range and a JSON body with a single error object. The code field is a stable string that your program reads. The message field is for a person, and its wording can change. The status field repeats the HTTP status.

{
  "error": {
    "code": "from_not_verified",
    "message": "from_email domain is not a verified sending domain for this account",
    "status": 422
  }
}
Branch on error.code. Do not branch on error.message. A code is part of the API contract, and it is stable across releases. A message is not.

A POST /v1/send/batch response uses the same shape in each entry. A failed item is { "success": false, "error": { … } }. A partial success is normal, and the API reports it item by item.

Error codes

The table gives the codes you meet most often. For each code it gives the HTTP status, the condition that raises it, and the recovery. Branch on code and not on the message text, because Sendara can add a code. Treat an unknown code as its HTTP status class.

One code carries two statuses. invalid_request returns 400 for a malformed body. It returns 422 on a suppression import that carries more than 10,000 entries.

Sendara error codes with HTTP status, trigger, and recovery
CodeStatusWhen it firesHow to recover
invalid_request400The request body is malformed, a required field is missing, or a value is out of range. This includes an unparseable pagination cursor.Read the message, fix the offending field, and retry. These never succeed on retry without a change.
unauthorized401The Authorization header is missing, malformed, or the API key is unknown, revoked, or expired.Send a valid Bearer key (Authorization: Bearer sk_live_…). If it was rotated or revoked, issue a new key.
invalid_signature403A provider callback or a billing webhook that Sendara receives failed HMAC verification, because of a wrong secret, an altered body, or a stale timestamp.Sendara returns this code on the endpoints it receives on. Recompute the signature with the correct signing secret over the raw body.
spend_cap_exceeded402The send would push the account past its configured hard spend cap for the period.Raise the cap in the dashboard or wait for the next billing period. Sends resume automatically once under the cap.
forbidden403The key authenticated, but its scope does not permit the operation. For example, a read key called POST /v1/send.Use a key with a sufficient scope (send, read, or admin). Mint a new scoped key if needed.
from_not_verified422The from_email domain is not a verified sending domain for the account.Add and verify the domain (publish the DKIM, MAIL FROM, and DMARC records), then send from an address on it.
recipient_not_verified403A test_send was addressed to an email that is not a verified test recipient for the account.Register the address as a test recipient and confirm it from the verification email, then retry.
not_found404The referenced resource (message, template, domain, key, contact, list) does not exist or belongs to another account.Check the id. Cross-account access to a resource id surfaces as not_found by design. A contact import S3 key outside your prefix is the exception, and returns 403 forbidden.
recipient_suppressed409The recipient is on the suppression list for this channel (hard bounce, complaint, or a manual suppression).Remove the suppression with DELETE /v1/suppressions if the address is genuinely valid. Otherwise, stop sending to it.
idempotency_key_reused409An idempotency_key was reused with a different request body than the one it first succeeded with.Use a fresh key for a new logical send. Retries of the same send must carry the same key and identical body.
duplicate_contact409A contact with the same email already exists in the account or target list.Treat the contact as already present, or update the existing record instead of creating a new one.
duplicate_member409The contact is already a member of the target list.No action needed. The membership already exists.
invalid_template400The template body has malformed Handlebars syntax, or a render or a preview ran against an invalid template.Fix the template markup so it parses, then re-render.
missing_variable400A render or send omitted a variable the template marks as required.Supply every required variable in template_vars and retry.
invalid_token400A one-time link token (password reset, unsubscribe) is invalid, already used, or expired.Request a fresh link. Tokens are single-use and time-bound.
too_many_test_recipients422Registering this test recipient would exceed the limit of 3 verified test addresses per account.Remove an existing test recipient before adding another.
test_send_daily_limit429The per-recipient daily test_send cap (10 sends per verified address per day) was reached.Wait for the daily window to reset, or send to a different verified test recipient.
payload_too_large413An upload exceeded its size limit. POST /v1/uploads accepts 2 MiB. A BIMI SVG logo accepts 1 MiB.Compress or resize the file under the limit for that endpoint, then upload it again.
rate_limit_exceeded429The account or IP exceeded its request budget for the current window.Wait the number of seconds given in Retry-After, then retry. Read the X-RateLimit-* headers to stay under the limit.
billing_not_configured503A billing operation (checkout, portal, webhook) was attempted while billing is not configured for the deployment.Configure billing, or contact support. This is an environment-level condition, not a per-request fault.
internal_error500An unexpected server-side failure. The request may or may not have taken effect.Retry with the same idempotency_key. A replay is safe and returns the original result if the first attempt landed. Contact support if it persists.
from_required422The account has a verified domain, so metadata.from_email is mandatory, and the send omitted it.Set metadata.from_email to an address on one of your verified domains.
no_verified_domain422The send needs a sending domain and the account has none verified.Verify a sending domain, then send from an address on it.
domain_not_verified422The domain in metadata.from_email exists on the account but has not finished DNS verification.Publish the DKIM, SPF and DMARC records Sendara gave you, then wait for the re-check.
domain_limit_reached403The account already holds 100 domains, which is the per-account limit.Delete a domain you no longer send from, or contact support.
invalid_destination422The destination object is missing the field the channel needs, or the value is not a valid address.Send destination.email for the email channel, with a syntactically valid address.
recipient_undeliverable422You set validate_recipient and the recipient domain publishes no MX, A or AAAA record.Correct the address. A misspelled domain is the usual cause.
channel_not_enabled422The account does not have the requested channel turned on.Send channel: "email". Email is the only channel Sendara sends today.
invalid_channel_payload422The payload object does not match the channel. An email send needs a subject and a body.Send payload.subject with payload.body_html or payload.body_text.
invalid_schedule422scheduled_at is more than 1 minute in the past, or more than 90 days in the future.Send an RFC 3339 time inside that window, or omit the field to send now.
template_not_found404template_id names a template that the account does not own.Check the template id. List your templates to find the right one.
template_error422The template failed to render with the variables you supplied.Preview the template with the same template_vars to see the failure.
batch_too_large413A batch send carried more than 1000 items.Split the batch into chunks of 1000 items or fewer.
account_suspended403The account is suspended, so the middleware rejects every request.Contact support. No request succeeds until the suspension is lifted.
test_send_not_configured503The deployment does not have test sends turned on.This is an environment-level condition. Contact support.
validation_not_configured503The deployment does not have recipient validation turned on.Send without validate_recipient, or contact support.

Status families

  • 400 / 422. The request is malformed, or it fails a business rule. Correct the input. An unchanged retry fails again.
  • 401 / 403. The key is invalid, or its scope is too narrow. Check the key, the scope and the signature.
  • 402. The send crossed a spend cap. Raise the cap, or wait for the next period.
  • 404. The resource is absent, including cross-account access.
  • 409. A conflict with existing state (suppression, idempotency, or a duplicate).
  • 413. The payload is too large.
  • 429. The limiter rejected the request. Read Retry-After, and wait for that many seconds.
  • 5xx. The server hit a transient condition. Retry with the same idempotency key.

Handle an error

Each SDK raises a typed error. The error carries code, status and message. A 429 may also carry retryAfter when the response includes aRetry-After header. Branch on the code, then recover as the table above describes.

handle.ts
import { RateLimitError, Sendara, SendaraError } from "sendara";

const sendara = new Sendara(process.env.SENDARA_API_KEY!);

async function sendReceipt() {
  try {
    await sendara.emails.send({
      from: "[email protected]",
      to: "[email protected]",
      subject: "Your receipt",
      html: "<h1>Thanks for your order</h1>",
    });
  } catch (err) {
    if (err instanceof RateLimitError) {
      await new Promise((resolve) =>
        setTimeout(resolve, (err.retryAfter ?? 1) * 1000),
      );
    } else if (err instanceof SendaraError) {
      switch (err.code) {
        case "recipient_suppressed":
          return;
        case "from_not_verified":
          throw new Error("Verify your sending domain first");
        default:
          throw err;
      }
    } else {
      throw err;
    }
  }
}

await sendReceipt();

Retries & idempotency

Retry 429 and 5xx responses with exponential backoff. Because every send carries an idempotency_key, a replay after a timeout or internal_error returns the original result instead of sending twice. Reusing a key with a different body is rejected with idempotency_key_reused, so retries must send the exact same payload.

Do not retry 4xx errors other than 429. They are deterministic and will fail again until you change the request.

Rate-limit headers

Authenticated API requests that reach the account limiter carry your remaining budget, so you can slow down before it rejects a request. Authentication failures, public routes, and requests handled before that limiter do not necessarily include these headers. A limiter rejection also returns Retry-After in seconds and the rate_limit_exceeded code.

  • X-RateLimit-Limit: the ceiling for the window.
  • X-RateLimit-Remaining: requests left in the window.
  • X-RateLimit-Reset: Unix epoch seconds when the window resets.

Verify an inbound signature

Sendara signs every webhook delivery, so you can prove that it came from Sendara. Each request carries Sendara-Signature, Sendara-Timestamp, Sendara-Event-Id, and Sendara-Event-Type. The signature is HMAC-SHA256 of "{timestamp}.{raw body}", hex-encoded, keyed with the subscription's signing secret. Verify over the raw request body before any JSON parsing or re-serialization, and compare in constant time.

verify.ts
import {
  verifyWebhook,
  WebhookVerificationError,
} from "sendara";

export function verifySendara(
  rawBody: string,
  headers: Record<string, unknown>,
  signingSecret: string,
): boolean {
  try {
    verifyWebhook(rawBody, headers, signingSecret, {
      toleranceSeconds: 5 * 60,
    });
    return true;
  } catch (error) {
    if (error instanceof WebhookVerificationError) return false;
    throw error;
  }
}
Your handler chooses its own status code for a webhook that fails verification. Sendara returns invalid_signature with status 403 on the endpoints that Sendara receives on, and those endpoints are not part of the outbound webhook flow. Reject every request that does not verify. Make your handler idempotent. Sendara can deliver one event more than once, and it always reuses the same Sendara-Event-Id across the retries.

Common questions

Which errors are safe to retry?
Retry a transient 5xx. Retry 429 rate_limit_exceeded after the number of seconds in Retry-After; other quota-style 429 errors may require a later reset or configuration change. Other 4xx responses are deterministic and fail again until you change the request.
Is a retry after a timeout safe?
Yes, when you resend the identical body with the same idempotency key. Sendara returns the original result in place of a second message. A key that you reuse with a different body returns 409 idempotency_key_reused.
What is the request rate limit?
Sendara counts authenticated API requests in a sliding window of 1 second, with a budget of 10 requests. Requests that reach the account limiter carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A rejected request returns 429 rate_limit_exceeded and a Retry-After header in seconds.
The resource exists, so why did I get 404 not_found?
The resource belongs to another account. Sendara reports cross-account access to a resource id as not_found by design, so that an id cannot be probed. A contact import is the exception: an S3 key outside your own prefix returns 403 forbidden. Check the id against the account that the key belongs to.
Why did my send return 409 recipient_suppressed?
The address is on your suppression list, from a hard bounce, a complaint or an entry that you added. Call DELETE /v1/suppressions to remove the entry when you know the address is valid. Do not remove an entry that came from a complaint.