Handling errors
import { Ewyn, EwynApiError } from '@ewyn/client';
try {
await client.send({ /* ... */ });
} catch (error) {
if (error instanceof EwynApiError) {
console.error('API Error:', error.status, error.message);
console.error('Details:', error.details);
if (error.status === 408) {
// Request timed out (error.code === 'TIMEOUT')
}
if (error.isRetryable()) {
// 429 or 5xx — the SDK already retried this automatically
}
}
}| Property / method | Meaning |
|---|---|
| status | HTTP status code; 408 indicates a client-side timeout |
| code | Optional error code, e.g. 'TIMEOUT' or 'NETWORK_ERROR' |
| details | Additional payload from the API (e.g. validation issues) |
| isClientError() | true for 4xx responses |
| isServerError() | true for 5xx responses |
| isRetryable() | true for 5xx and rate-limit 429s — what the SDK retries automatically. Quota 429s (QUOTA_EXCEEDED, ATTACHMENT_QUOTA_EXCEEDED) are not retried |
| isSuppressed() | true when the recipient is blocked for this send (422 RECIPIENT_SUPPRESSED) — a delivery block or a marketing opt-out |
| isOptedOut() | true when the block is a marketing opt-out (the recipient unsubscribed from the topic or from all marketing); a transactional send with the same type would go through |
| suppressionDetails() | { kind, reason, topic?, messageId } for a blocked send, else undefined |
Blocked recipients
A send to a blocked recipient fails with 422 and is not retryable — the block has to be removed first. The attempt is still recorded, so the message appears in your dashboard with a Suppressed status and can be sent manually from there if it was blocked by mistake. Blocked sends do not count against your quota.
suppressionDetails().kind tells the two cases apart: 'suppression' is a delivery block (HARD_BOUNCE and MANUAL stop every send, COMPLAINT stops marketing sends), and 'opt_out' is a marketing unsubscribe (OPT_OUT_TOPIC with the topic slug, or OPT_OUT_ALL). Opt-outs never block a send with type: 'transactional'.
try {
await client.send({ type: 'marketing', topic: 'newsletter', /* ... */ });
} catch (error) {
if (error instanceof EwynApiError && error.isSuppressed()) {
const { kind, reason, topic, messageId } = error.suppressionDetails() ?? {};
// kind: 'suppression' | 'opt_out'
// reason: 'HARD_BOUNCE' | 'MANUAL' | 'COMPLAINT' | 'OPT_OUT_TOPIC' | 'OPT_OUT_ALL'
// topic: the slug, for OPT_OUT_TOPIC
if (error.isOptedOut()) {
// They unsubscribed; don't resend marketing. A receipt or reset for the
// same person still goes through with type: 'transactional'.
}
console.log(`Not sent (${reason}). Review message ${messageId} in the dashboard.`);
}
}suppressionDetails() also reads the flat reason that servers before v0.15 answered with, and derives kind from it; an UNSUBSCRIBE reason counts as an opt-out.
Automatic retries
The SDK retries on:
429Too Many Requests — rate limits only; a 429 with codeQUOTA_EXCEEDEDorATTACHMENT_QUOTA_EXCEEDEDis not retried5xxserver errors- Network errors
Retries use exponential backoff with a 1-second base delay. With the default maxRetries: 3 the SDK makes up to 3 total attempts (initial + 2 retries) with delays of 1s and 2s. Timeouts (408) and other 4xx errors are not retried.
Idempotency
Provide an idempotencyKey on sends so retries — yours or the SDK's — can never produce duplicate emails:
await client.send({
to: 'user@example.com',
subject: 'Your receipt',
templateId: 'template-version-uuid',
type: 'transactional',
idempotencyKey: `order-confirmation-${order.id}`,
variables: { total: order.total },
});A repeated idempotencyKey within 24 hours returns the original response instead of sending again. If the original send terminally failed, the retry re-queues it — you never get silent loss. The one exception is a failed send whose attachments have since been deleted (6-month retention): the replay returns 410 ATTACHMENTS_EXPIRED and you should send again with a new key.
Wondering what the server enforces (rate limits, validation, status codes)? See API errors & rate limits.