{ error: { code, message, fix } }, with requestId added over REST. Branch on code — it is the contract. The message is written for a person and may be reworded.
Every refusal carries a
fix. That is enforced by the type: it is a required argument, so an error that does not say what to do next cannot be constructed. An agent holding one has its next step named for it.HTTP 400
| Code | Message | Fix |
|---|---|---|
audience_too_large | This audience has {total} contacts; a single broadcast sends to at most {MAX_RECIPIENTS}. | Narrow the segment and send in parts. |
confirmation_mismatch | That is not this organization’s name. To delete it, pass name exactly as “{org.name}”. | Nothing was changed. The name is a confirmation, because this removes every member’s access at once and cannot be undone. |
contrast_too_low | {failures.length} colour pair{…} fall below WCAG AA (4.5:1): {…}. | Set {…}, or choose different backgrounds. Every value shown was computed to pass. |
environment_required | A dashboard session must name the environment it is acting in. | Send the {CONSOLE_ENV_HEADER} header with an environmentId from your workspaces. |
heard_from_required | heardFromDetail describes a channel, and this account has not said how it heard about Lath. | Send heardFrom alongside heardFromDetail, for example { heardFrom: "referral", heardFromDetail: "a friend" }. |
inbound_empty | The message is empty. | Send the raw RFC 822 message as the body. |
inbound_no_recipient | The envelope recipient is missing or not an address. | Pass RCPT TO as envelopeTo (JSON) or x-lath-envelope-to (message/rfc822). The To header is the sender’s text and never routes a message. |
inbound_no_sender | The message has no From header and no envelope sender. | Every message needs a sender. |
invalid_definition | built at the call site | Fix the segment definition and retry. |
invalid_domain | ”{input}” is not a bare domain name. | Pass the domain only, like mail.example.com, without a scheme, path or port. |
invalid_input | ”{input}” cannot be a return-path subdomain. | Use one label of letters, digits and hyphens, like send or bounces — not a dotted name. |
invalid_json | The request body is not valid JSON. | Send a JSON object with content-type application/json. |
missing_variables | The message references {…} but no value was given. | Add {…} to variables. Nothing was sent. |
no_usable_signin_method | This would leave the environment with no sign-in method anyone can use. {why} | Leave at least one method that is both enabled and available — auth.settings.get reports availability for each, and emailCode is always available. Nothing was changed. |
not_a_link | This challenge is a code, not a link. | Enter the code in the app instead. |
passkey_rejected | The authenticator’s response could not be verified: {…} | Make sure the page runs on one of the project’s allowed origins and try again. |
password_breached | That password has appeared in {…} known data breaches. | Choose a password you have never used anywhere else. |
password_too_long | The password has {chars} characters; the limit is {rules.maxLength}. | Use {rules.maxLength} characters or fewer. |
password_too_short | The password has {chars} characters; at least {rules.minLength} are required. | Use {rules.minLength} characters or more. A sentence works well. |
sms_too_long | This message is {size.segments} segments ({size.units} {…} in {size.encoding}), and the limit is {MAX_SEGMENTS}. | names the specific value; arrives on the response |
state_mismatch | That sign-in did not come from this browser. | Start again with auth.oauth.start. |
template_invalid | Inline content has {problems.length} syntax problem{…}: {…}. | Fix each named line, or escape literal braces by writing them outside {{ }}. |
template_missing_required | An override of {name} must show {…} in every body it has: {…}. Without it the message cannot do its job. | Add the variable to each named part. Variables available: {…}, plus project and style.*. |
wrong_code | That code is not right. {…} attempts left. | Ask the user to re-enter the code from the message. |
HTTP 401
| Code | Message | Fix |
|---|---|---|
code_expired | That code has expired. | Codes live two minutes. Start the sign-in again. |
code_invalid | That code is not recognised in this environment. | Codes are single-use and environment-bound; start the sign-in again. |
code_used | That code was already exchanged. | A code works once. Start the sign-in again if the tokens were lost. |
invalid_credentials | That address and password do not match. | Check both, or sign in with a code instead. |
mfa_token_invalid | This sign-in is not waiting for a second factor. | Start the sign-in again. |
refresh_reused | This refresh token was already used once. Every session in its family has been revoked. | Sign the user in again. A refresh token used twice means it may have leaked; check where the copy came from. |
session_expired | This refresh token has expired. | Sign the user in again. |
session_impersonated | An impersonated session cannot be refreshed. | It lasts up to thirty minutes and then ends. Call auth.user.impersonate again to continue, which records a fresh reason. |
session_invalid | That refresh token is not recognised. | Sign the user in again. |
session_revoked | The session behind this code was revoked. | Sign the user in again. |
token_invalid | This dashboard session has been signed out. | Sign in to the dashboard again. |
unauthenticated | No valid key was presented. | Send Authorization: Bearer <key> with a key from Developers → Keys or developers.key.create. |
HTTP 402
| Code | Message | Fix |
|---|---|---|
billing_past_due | This account has an unpaid invoice {standing.days} days overdue, so bulk sending is paused. Transactional messages — sign-in codes, receipts, anything your own users triggered — are unaffected and still send. | Add a working card with billing.payment.setup. Sending resumes as soon as the outstanding invoice is collected. |
HTTP 403
| Code | Message | Fix |
|---|---|---|
destination_not_allowed | This deployment does not send to +{code} numbers, and {to} is one. | The allowed country codes are {…}. An operator changes them with LATH_SMS_ALLOWED_COUNTRIES. This list exists because the profitable destinations for SMS fraud are the ones a normal product never sends to. |
forbidden | You are not a member of the account that owns this environment. | Pick an environment from your workspaces, or ask an owner to invite you. |
invite_wrong_account | This invitation was sent to {inv.email}, which is not one of your verified addresses. | Sign in with that address, or add it to this account first. |
invite_wrong_address | This invitation is for {inv.email}, and you are signed in as {s.email}. | Sign in to the console as {inv.email} to accept it. |
live_only | {operation} changes what live users receive, so it needs the live environment. | Call it with the live key (or the live environment in the dashboard); test keys can draft and read. |
method_disabled | Sign in with {input.provider} is turned off in this environment’s sign-in settings. | Enable it with auth.settings.set {"methods":{"{input.provider}":true}}, or use a method auth.settings.get lists as offered. |
mfa_required | {what} needs a session that passed two-step sign-in. | Sign in again and enter the code from your authenticator app (or a recovery code), then retry. |
owner_only | Only an owner can invite another owner. | Invite them as an admin, or ask an owner. |
permission_exceeds_caller | You cannot grant {…}: this caller does not hold {…}. | Create the key from a caller that holds every permission it should carry, or ask an owner. |
reauth_required | {what} needs a recent sign-in. | Sign in again (a fresh code, password or passkey), then retry. |
signup_closed | This environment does not accept new users, and no account exists for that address. | Create the user first with auth.user.create, or allow sign-ups with auth.settings.set {"signup":{"allow":true}}. |
signup_domain_blocked | This environment does not create accounts for addresses at {verdict.domain}. | names the specific value; arrives on the response |
signup_invite_only | This environment only creates accounts for invited addresses, and this address has no invitation. | Invite the address with auth.invitation.create, or open sign-ups with auth.settings.set {"signup":{"inviteOnly":false}}. |
test_mode_recipient | {addr} was invited to this account but has not accepted yet, so a test environment cannot mail it. | Open the invitation on that address and sign in once — account.member.accept — and the seat becomes active. Until an address answers, nothing shows anyone reads it. |
user_banned | This user is banned and cannot sign in. | Unban with auth.user.unban if that is wrong. |
HTTP 404
| Code | Message | Fix |
|---|---|---|
device_code_unknown | That code is not waiting for approval. It may have expired or been typed wrong. | Ask the device to start again and read the new code. |
domain_unknown | {…} is not a domain any project has turned receiving on for. | Inbound is accepted only for a domain a customer has verified with email.domain.verify and then enabled with email.domain.receiving.set. Verifying a domain to send from does not make it receive. |
invite_invalid | This invitation is not open. | Ask an owner to invite you again. |
not_found | Project not found. | Check the key belongs to a live project. |
topic_unknown | There is no topic “{input.topic}” in this environment, so consent for it could never be shown to the person or used by a broadcast. | Create it first with audience.topic.create {"key":"{input.topic}","name":"…"}, or use audience.topic.list to see the keys that exist. |
HTTP 409
| Code | Message | Fix |
|---|---|---|
account_closed | This account was closed on {…}``{…}, so nothing sends from it. Nothing was sent. | Reopen it with account.reopen, or from the account page in the dashboard. Everything is kept for 30 days after closure; after that the account’s data is erased and only invoices, suppressions and the closure record remain. |
account_empty | The account has no project yet. | Ask an owner to create a project first. |
account_open | This account is not closed. | Nothing to do. |
active_signing_key | {input.kid} is the key signing new tokens right now, so retiring it would stop every current sign-in from verifying. | Call auth.signingKey.rotate first — that mints a new key and makes it active — then retire this one. |
address_suppressed | {…} {address} {…} suppressed, so {what} cannot be delivered: {may.detail}. | names the specific value; arrives on the response |
already_a_reversal | That row is itself a reversal. | Reverse the original instead, or record a new credit if the intent is to give money back again. |
already_a_user | {input.email} already has an account in this environment. | They can sign in now; no invitation is needed. auth.user.list finds them. |
already_archived | The topic {input.key} is already archived. | Nothing to do. audience.topic.restore brings it back if that was not what you meant. |
already_member | {email} is already a member of {org.name}. | names the specific value; arrives on the response |
already_registered | This project’s carrier registration is already active. | Nothing to do. Use sms.registration.get to read it. |
already_retired | Signing key {input.kid} is already retired. | Nothing to do. auth.signingKey.list shows when it was. |
already_reversed | That credit has already been reversed. | The reversal is {already.id}. Record a new credit if the intent is to give money back again. |
already_sent | ”{b.name}” has finished sending; there is nothing left to cancel. | Messages already delivered cannot be recalled. Suppress addresses you must not mail again with audience.suppression.add. |
automation_archived | Automation “{a.name}” is archived and cannot change. | Create a new automation. |
automation_not_active | Automation “{a.name}” is {a.status}. | Only an active automation can be paused. |
broadcast_not_draft | Broadcast “{b.name}” is {b.status} and cannot change. | Create a new broadcast instead. |
channel_unavailable | Phone numbers cannot be added until SMS sending is wired. | Add an email address meanwhile. |
contact_exists | A contact with email {email} already exists ({r.rows[0].id}). | Use audience.contact.update on that contact, or audience.contact.import which merges by address. |
delivery_in_flight | This delivery is {exists.status} and will be attempted on its own. | Wait for it to finish, then replay if it failed. |
domain_ambiguous | {…} is verified in {owners.length} projects, so its inbound mail has no single owner. | Remove the domain from every project but one with email.domain.remove; mail for it is refused until then. |
domain_exists | {domain} is already added to this project. | Read it with email.domain.get {"domainId":"{dup.rows[0].id}"} or verify it with email.domain.verify. |
domain_not_verified | {row.domain} is {row.status}, and only a verified domain can receive mail. | names the specific value; arrives on the response |
email_taken | An account already exists for this email. | Sign in to that account instead, or use a different email for a new one. |
endpoint_disabled | This webhook endpoint is disabled, so a test would never be delivered. | Create a new endpoint with developers.webhook.create, or test a different one. |
event_not_subscribed | This endpoint’s event filter does not include developers.webhook.test, so a test would never be delivered. | Add “developers.webhook.test” or “developers.*” to its event types with developers.webhook.update, or test an endpoint that subscribes to everything. |
factor_not_pending | This factor is {f.status}. | Enrol a new one with auth.factor.totp.enroll. |
idempotency_mismatch | This idempotency key was already used with different input. | Use a new idempotency key for new input, or resend the original input. |
identity_exists | {email} is already one of this user’s addresses. | Nothing to add. |
identity_taken | {email} cannot be added to this account. | Use a different address, or sign in to the account that address already belongs to. |
invitation_accepted | The invitation to {row.email} was already used to create an account. | Nothing to revoke. To remove that user, use auth.user.delete. |
label_taken | {label}.{SENDING_PARENT} already belongs to another project. | Choose a different label, or send from a domain you own after email.domain.add and email.domain.verify. |
last_identity | This is the only address on the account; it cannot be removed. | Add another address with auth.identity.add first. |
last_key | This is the last key with developers:write in this environment. | Create another key with developers:write first, then revoke this one. |
last_number | {row.phoneNumber} is this environment’s only active number, and sign-in codes and texts are sent from it. | Buy another number first (sms.number.search, then sms.number.buy), then release this one. |
last_owner | This is the account’s only active owner. | Make another active member an owner first with account.member.update, then change or remove this one. |
legal_address_missing | The project has no postal address in its email footer; marketing mail must carry one. | account.theme.set {"copy":{"email.legal_address":"<your postal address>"}} then account.theme.publish. |
member_exists | {email} is already an active member ({existing.role}). | Change their role with account.member.update if that is what you meant. |
mfa_code_required | This account already has a second factor. | Finish signing in with auth.mfa.verify and a code from the app, then manage factors with the access token. |
mfa_enrol_required | This account has no second factor yet and this environment requires one. | Enrol with auth.factor.totp.enroll and auth.factor.totp.activate using this mfaToken. |
mfa_factor_missing | The account’s second factor is gone. | Start the sign-in again. |
mfa_required_by_org | {…} require{…} two-step sign-in of members. | Leave the organization, or enrol a replacement first. |
mfa_required_here | This environment requires two-step sign-in for everyone. | Enrol a replacement first with auth.factor.totp.enroll, or ask the operator to change the setting. |
no_{ch}_address | built at the call site | Nothing to do. The preferences page only offers a channel the contact has an address for. |
no_billing_customer | This account has never started adding a card. | Call billing.payment.setup and complete the page it returns. |
no_draft | Template “{input.name}”{…} has no draft to publish. | Save one with email.template.set{…} first. |
no_factor | This account has no active second factor. | Enrol one with auth.factor.totp.enroll. |
no_password | This account has no password. | Nothing to remove. |
no_redirect | This link has nowhere to send you. | The application must start email_link sign-ins with a redirectTo. |
not_archived | The topic {input.key} is not archived. | Nothing to do; it is already in use. |
not_paused | ”{b.name}” is not paused. | Nothing to resume. email.broadcast.get shows its state. |
not_sending | ”{b.name}” is {…}. | names the specific value; arrives on the response |
number_already_held | {input.phoneNumber} is already this environment’s number. | Nothing to buy. sms.number.list shows the numbers this environment holds. |
number_request_pending | This environment is already waiting on a number from the carrier. | Wait for it to become active or fail (sms.number.list), then buy another. |
opt_out_missing | SMS template “{s.template}” never tells the recipient how to stop. | Add opt-out wording such as “Reply STOP to unsubscribe” to the template. It is not added for you, because adding it would change the segment count you measured. |
owner_elsewhere | {m.email} already owns another account; one address owns at most one account. | Make them an admin here, or have them transfer the other account first. |
passkey_unconfigured | Passkeys need an origin to bind to and none is configured. | Set redirect.allowedOrigins (or passkey.rpId and passkey.origins) with auth.settings.set. |
publishable_key | A publishable key carries exactly one permission (auth:public), and it cannot be changed. | Create a secret key for anything more and keep it on your server. |
quiet_hours_timezone_unset | Quiet hours are on ({quietHours.start}–{quietHours.end}) but no timezone is set, so there is no clock to judge them against. | Set one with sms.settings.set {"quietHours":{"timezone":"America/Los_Angeles"}}, or turn quiet hours off deliberately with {"quietHours":{"enabled":false}}. |
redirect_not_allowed | {verdict.origin} is not an allowed redirect origin for this {mode} environment. | Add it once with auth.settings.set {"redirect":{"allowedOrigins":[{…}]}}. Test environments accept http://localhost without listing it. |
registration_in_flight | This registration is registering and cannot be changed until the carrier’s answer moves it. | Wait for status to become active, action_needed or failed (sms.registration.get), then resubmit if asked. |
run_finished | This run is already {r.status}. | Nothing to cancel. |
segment_unknown | Segment {b.segmentId} does not exist. | Pick one from audience.segment.list. |
sender_unverified | {input.from} is not on a verified sending domain of this project. Its own sending domain is {…}, and any address there may be used. | Send from any address at {…}, or add and verify the domain: email.domain.add {"domain":"{…}"}, create the records it returns, then email.domain.verify. |
slug_taken | The slug “{input.slug}” is already used in this environment. | Choose another slug, or omit it to have one derived from the name. |
suppression_protected | This suppression is a {s.reason} and cannot be lifted by hand. | Nothing an API key does lifts it, audience.consent.grant included. It lifts only when the person subscribes to a topic again on the hosted preferences page, from the link in a message sent to that address. Sending to a known bounce or complaint address damages deliverability for every customer. |
template_empty | SMS template “{b.template}” has an empty body. | Give it a body with sms.template.set, then publish it. |
template_reserved | ”{b.template}” is a sign-in template and cannot be broadcast. | Create a marketing template with email.template.set. |
template_unknown | Template “{b.template}” does not exist or is not published. | Publish it with email.template.publish, or list templates with email.template.list. |
topic_exists | A topic with key “{input.key}” already exists. | Use audience.topic.update to change it, or a different key. |
unsubscribe_missing | Template “{templateName}” uses layout “none” and never references {{unsubscribeUrl}}. Every marketing message must carry a working unsubscribe link. | Add {{unsubscribeUrl}} to the template, or switch it to layout “brand”, which adds the footer for you. |
HTTP 410
| Code | Message | Fix |
|---|---|---|
challenge_expired | This code or link has expired. | Start a new sign-in with auth.signin.start. |
challenge_spent | This code or link was already used, or ran out of attempts. | Start a new sign-in with auth.signin.start. |
device_code_consumed | This device code was already redeemed. | Start a new device sign-in. |
device_code_denied | The session that approved this device has since been signed out. | Start a new device sign-in and approve it again. |
invite_expired | This invitation has expired. | Ask an owner to invite you again; invitations last seven days. |
invite_spent | This invitation was already used or withdrawn. | Ask for a new invitation. |
mfa_token_expired | This sign-in took too long. | Start the sign-in again. |
mfa_token_spent | This sign-in was already completed or ran out of attempts. | Start the sign-in again. |
HTTP 413
| Code | Message | Fix |
|---|---|---|
attachments_too_large | The attachments come to {…} MB and the limit is {…} MB for one message. Nothing was sent. | Send fewer or smaller files, or link to them instead. Many mailboxes reject large mail outright, so a message accepted here can still be refused at the far end. |
body_too_large | The request body is {declared} bytes, and the limit is {MAX_BODY_BYTES}. | Send less. An email’s HTML is capped at 200 KB and its text at 100 KB by the operation’s own schema, so a body this size is not one this API accepts. |
inbound_attachments_too_large | Attachments total {attachmentBytes} bytes; the limit is {MAX_ATTACHMENT_BYTES}. | Messages with more than 10 MB of attachments are not accepted. |
inbound_too_large | The message is {rawSize} bytes; the limit is {MAX_INBOUND_BYTES}. | Messages over 25 MB are not accepted. |
HTTP 422
| Code | Message | Fix |
|---|---|---|
ai_declined | The model declined to write this email. | Describe a different email. Requests for deceptive or harmful mail are declined. |
HTTP 429
| Code | Message | Fix |
|---|---|---|
auth_send_limited | This environment has sent as much sign-in and invitation mail as it is allowed in this window{…}. | The limit is {…} an hour and {…} a day per environment, and it applies to mail Lath sends on your behalf rather than to email.send. It exists because auth.signin.start is reachable with a publishable key, which is public by design. If this is legitimate traffic, an operator raises LATH_AUTH_SENDS_PER_HOUR; if it is not, a key has leaked and rotating it is the fix. |
rate_limited | Too many calls for this key and operation. | Retry after {retryAfterSeconds} seconds. |
slow_down | Poll at most every {DEVICE_POLL_INTERVAL_S} seconds. | Wait {DEVICE_POLL_INTERVAL_S} seconds between polls. |
test_mode_limit | This test environment has sent {…} messages in the last 24 hours, and the limit is {TEST_MODE_DAILY_LIMIT}. | Wait for the window to move, or send from the live environment, which has no daily limit. Test sends are not billed, so this is the only thing standing between a loop and a reputation problem. |
too_fast | That was answered faster than a person reads an email. | New accounts wait {…}s between the code being sent and accepted. Start again, or turn it off with auth.settings.set {"botProtection":{"minChallengeAgeSeconds":0}}. |
HTTP 500
| Code | Message | Fix |
|---|---|---|
data_key_invalid | {name} must be 32 bytes, base64 encoded (got {key.length} bytes). | Generate one with: openssl rand -base64 32 |
data_key_missing | LATH_DATA_KEY is not set, so secrets cannot be stored or read. | Set LATH_DATA_KEY to 32 random bytes, base64: openssl rand -base64 32 |
data_key_unknown | A stored secret was sealed with key {kid}, which this process does not hold. | Set LATH_DATA_KEY_PREVIOUS to the key that sealed it, then re-seal by re-creating the resource. |
internal | Read operation {def.name} tried to emit {type}. Reads change nothing. | Make it a mutation, or remove the emit. |
output_mismatch | {def.name} returned something its declared output does not describe: {detail} | This is a defect in Lath rather than in the request. The operation ran; only the response shape is wrong. |
secret_unreadable | A stored secret is not in the sealed format. | This row predates envelope encryption or was written by hand; re-create the resource. |
signing_key_unsealed | Signing key {kid} is stored unsealed. | Retire it (set retired_at) so a sealed key is minted on the next sign-in. |
HTTP 502
| Code | Message | Fix |
|---|---|---|
ai_invalid_output | The draft that came back was not a usable email: {why}. | Try again, or describe the email more specifically. |
carrier_refused | The carrier did not answer the search: {…} | Try again in a moment, or search a different area code. |
oauth_provider_error | The provider refused the {what} step ({res.status}). | It said: {…} |
HTTP 503
| Code | Message | Fix |
|---|---|---|
ai_busy | Writing emails is busy right now. | Try again in a minute. |
ai_not_configured | Writing emails from a description is not switched on for this deployment. | An operator sets LATH_ANTHROPIC_API_KEY on the API and restarts it. Until then, build the email from blocks, or have your connected agent write it with email_template_set. |
ai_unavailable | The email could not be written just now. | Try again in a moment. Your current draft is unchanged. |
breach_check_unavailable | The password breach check could not be reached, so the password was not set. | Try again in a moment. An operator can turn the check off with auth.settings.set {"password":{"breachCheck":false}}, which is not recommended. |
dns_unavailable | DNS lookup for {name} failed: {…}. | Try email.domain.verify again in a minute; if it keeps failing the resolver, not your records, is the problem. |
payments_unavailable | This deployment has no payment processor configured, so no card can be added and nothing is being charged. | An operator sets LATH_STRIPE_SECRET_KEY. Until then usage is measured and reported (billing.usage.get) and never collected. |
provider_unconfigured | Sign in with {args.provider} is not configured on this deployment. | Set {names.id} and {names.secret}, or give this project its own app with auth.oauth.app.set. |
sms_carrier_unavailable | This deployment has no SMS carrier configured, so no number can be searched for, bought or released. | An operator sets the Twilio credentials (LATH_TWILIO_ACCOUNT_SID, LATH_TWILIO_AUTH_TOKEN and a messaging service); see docs/deploy.md. |
try_again | Could not allocate a user code. | Call auth.device.start again. |

