Skip to main content
Guides File 040

Errors & retries

One normalised PostboiError across every provider, plus opt-in retries.

Error handling

Every provider throws the same normalised PostboiError on failure: HTTP errors, provider error envelopes, timeouts, and network failures all funnel through it. Error handling is identical no matter which provider you use, and the original provider payload is kept on .raw.

try {
	await mail.send({ to: 'bad@email', body: 'test' })
} catch (error) {
	if (mail.is_error(error)) {
		// error is a PostboiError
		console.error(error.provider) // e.g. "resend"
		console.error(error.status) // HTTP status, when applicable
		console.error(error.code) // provider-specific code, when available
		console.error(error.message) // normalised message
		console.error(error.raw) // the original provider payload
		console.error(error.outcome_unknown) // true when it may have been sent anyway
		console.error(error.reason) // why it was refused: "auth", "recipient", … (see below)
		console.error(error.retry_after) // seconds the provider's Retry-After asked for, if it sent one
	} else {
		console.error(error)
	}
}

Why it was refused

reason says why a provider refused, the same words whichever provider it was: auth, rate_limited, sender, account, recipient, compliance or request. It comes from the provider’s own error code where postboi knows it (Twilio’s, AWS’s), and from the HTTP status otherwise. It is never set on an unknown outcome, since nothing was refused.

The first four are about the provider rather than the message, so another provider might accept the same message; that is what an SMS or WhatsApp provider chain fails over on, and fails_over(error) is the same test. A chain that ran out of providers throws a FailoverError (still a PostboiError) whose errors lists every answer.

Common codes

Beyond provider-specific codes passed through on .code, postboi itself uses a few:

Code Meaning
skipped / spam The send was intentionally cancelled: a before.send hook or the honeypot.
provider_chain_not_supported Several providers named on a channel that sends through one (push, chat).
no_provider / no_token / unknown_provider / missing_env Zero-config resolution failed. See Quick start. In development a missing credential logs instead of throwing.
cancel_not_supported cancel() on a provider without a cancellation API.
webhooks_not_supported receive() for a provider that emits no delivery events.
invalid_payload A webhook body that doesn’t parse.

Webhook verification failures throw a WebhookVerificationError (a PostboiError subclass) with codes invalid_signature, missing_secret, stale_timestamp or unsupported_runtime. Return a 401 when you catch one.

Retries

Every provider accepts retries, retry_delay, and timeout:

// postboi.config.ts
import { config } from 'postboi'

export default config({
	timeout: 30000, // per-request timeout in ms (default 30000)
	retries: 2, // retries on 429; on 5xx and network errors only with an idempotency_key (default 0)
	retry_delay: 500 // base backoff in ms, doubles each attempt (default 500)
})

Nothing is retried until retries is above 0. Then what gets retried depends on what the failure proves. The delay doubles each attempt (exponential backoff), and a Retry-After from the provider (in seconds or as a date) is honoured up to a minute. When the retries run out, or none were asked for, the error’s retry_after says what the provider asked for, so a queue of your own can wait it out.

Failure Retried outcome_unknown
429 Yes. The provider refused the request. false
5xx, timeout, network error Only when the send carries an idempotency_key the provider honours. true
Any other 4xx Never. Sending it again gets the same answer. false

A 5xx or a dropped connection doesn’t mean the message didn’t go. The provider may have queued it before answering badly or not at all, so sending it again can deliver it twice. That is why those retries wait for a key the provider deduplicates on, and why the error says outcome_unknown: true when they run out: look the message up with the provider before you send it again yourself.

Providers that honour a key: the Postboi provider, Resend (a batch too), AhaSend, Lettermint, JetEmail, Loops, Primitive, Klaviyo (unique_id), HubSpot (sendId) and Azure (a UUID, as its Operation-Id; a batch’s per-recipient keys aren’t UUIDs, so a batch through Azure isn’t keyed). A failed read (a GET) is never outcome_unknown, since it can’t have sent anything. None of the SMS providers documents one for sending, Twilio included, so a text is never retried after a 5xx.

If a duplicate is better than a drop for you, retry_unknown: true retries those failures without a key too.

Retries are off by default on purpose. Set retries and pass an idempotency_key on sends to a provider that honours one.