POST /v1/parse is synchronous: you send a raw email and a JSON Schema, and the typed result
comes back in the same HTTP response. That keeps integration simple, but it also means every
failure shows up in your request path. Your client has to decide, for each failure, whether to
retry, fix the request, or send the email to a human.
This guide sorts the failures you can see today into those three groups. It sticks to what the API docs and FAQ document. It does not cover asynchronous delivery: signed webhooks, delivery retries, and async parsing are on the roadmap and not yet available, so there is no callback to wait for. Your client holds the connection open and handles the outcome itself.
Two kinds of failure
A parse can go wrong in two different places, and they look different on the wire.
- The request fails. You get a non-2xx status and an error envelope. No parse result exists.
- The request succeeds but the result is not clean. You get a 2xx status, the typed
data, and a non-emptyvalidation_errorsarray. The docs state that failed validation does not error the request; both the data and the errors are returned so you can decide what to do.
Treat these as separate branches in code. Mixing them is the most common mistake: a client that
only checks res.ok will store results that failed schema validation, and a client that retries
on any problem will re-send emails that can never pass.
Status codes and what to do
The error body uses one envelope for every non-2xx response:
{
"error": {
"type": "invalid_request",
"message": "Unknown schema: stripe_receipt",
"field": "schema_id",
"request_id": "req_8f2a1c"
}
}
| Status | Documented meaning | Your action |
|---|---|---|
| 200 / 201 | Success; typed data plus any validation_errors | Check status and validation_errors before storing |
| 400 | Bad JSON, unknown schema, or malformed input | Fix the request. Do not retry unchanged |
| 401 | Missing or invalid API key | Fix the key. Do not retry unchanged |
| 422 | Extraction failed; no parse result was produced | Do not blind-retry. Route the email for review |
| 429 | Rate or plan limit reached | Back off and retry, or stop if the plan limit is the cause |
| 5xx | Server error; documented as safe to retry with backoff | Retry with capped exponential backoff |
Two rows deserve extra care.
429 can mean two things. The docs describe it as “rate or plan limit reached.” A short rate limit clears if you wait. A plan limit does not clear until the next billing cycle or until you upgrade, so a retry loop that never gives up will just keep failing. Cap your attempts and alert a person when the cap is hit. The FAQ notes there are no retroactive overage charges when you hit a plan limit.
422 is not a transient error. It means extraction produced no result for that input. Sending the identical email again gives you no new information, so retrying belongs behind a human or a changed input (for example, a corrected schema), not a timer.
The docs and FAQ do not document a Retry-After header or an idempotency key for this endpoint,
so do not write code that depends on either. Use your own backoff schedule and your own
caller-side deduplication.
Set your own client timeout
The configured request timeout for a parse is 30 seconds, and the FAQ asks clients to set their own HTTP timeout accordingly. Choose a value of your own that fits your latency budget, and decide in advance what a timed-out request means for you. A timeout tells you only that you did not receive a response in time, not whether the server finished. Treat it like a 5xx for retry purposes, and make sure the retry cannot create a duplicate record on your side.
A small, honest client
The excerpt below shows the branching, not a drop-in module. schema, rawMime, saveRecord,
holdForReview, alertOperator, and logger stand in for your own code. It must run
server-side, because it reads your API key from the environment.
// Server-side only. MAILFRAME_API_KEY is a secret; never bundle it into browser code.
// schema, rawMime, saveRecord, holdForReview, alertOperator, logger: provided by your application.
const MAX_ATTEMPTS = 4;
async function parseEmail(schema: object, rawMime: string) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let res: Response;
try {
res = await fetch("https://api.mailframe.ai/v1/parse", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ schema, raw_mime: rawMime }),
signal: AbortSignal.timeout(35_000), // your own client timeout
});
} catch (err) {
// Network failure or client timeout: outcome unknown, treat as retryable.
await backoff(attempt);
continue;
}
if (res.ok) {
const { data, status, validation_errors = [] } = await res.json();
if (status === "completed" && validation_errors.length === 0) {
return saveRecord(data);
}
// Request worked, result did not validate. Retrying the same input will not help.
return holdForReview({ rawMime, status, validation_errors });
}
const body = await res.json().catch(() => null);
const requestId = body?.error?.request_id;
logger.warn({ status: res.status, requestId, type: body?.error?.type });
if (res.status === 429 || res.status >= 500) {
await backoff(attempt); // capped exponential backoff with jitter
continue;
}
if (res.status === 422) {
return holdForReview({ rawMime, requestId, reason: "extraction_failed" });
}
// 400, 401, and anything else: a request problem a retry cannot fix.
throw new Error(`MailFrame ${res.status} (request_id ${requestId})`);
}
await alertOperator("MailFrame parse retries exhausted");
return holdForReview({ rawMime, reason: "retries_exhausted" });
}
function backoff(attempt: number) {
const ms = Math.min(1000 * 2 ** (attempt - 1), 8000) * (0.5 + Math.random() / 2);
return new Promise((r) => setTimeout(r, ms));
}
A few choices in that excerpt are worth keeping even if you rewrite the rest:
- The loop gives up. An unbounded retry on 429 or 5xx turns an outage or an exhausted plan into a stuck queue. After the last attempt the email goes to review and a person is told.
- The error branch is by status, not by message text. Messages are written for people and can
change. Branch on the status code and, if you need finer detail, on
error.type. - A 2xx with
validation_errorsnever retries. The same email against the same schema is expected to produce the same kind of problem. Look at the schema first; if a field is required but often absent, the fix is in the schema, not the retry policy.
Log the request ID
Every error envelope carries a request_id. Write it to your logs next to your own message ID.
When something fails in a way you do not understand, that pair lets you point at one specific
request instead of describing a time window. Do not log the raw email body or your API key along
with it: raw email often contains personal data, and the key is a secret.
Checklist
- The client branches on HTTP status first, then on
statusandvalidation_errors. - 400 and 401 are never retried unchanged.
- 422 and non-empty
validation_errorsroute to review, not to a retry loop. - 429 and 5xx retry with capped, jittered backoff and a hard attempt limit.
- Your own HTTP timeout is set, and a timeout is handled like a retryable failure.
- A retry cannot create a duplicate record in your system.
-
error.request_idis logged; raw email bodies and API keys are not. - You have sent a malformed request, a bad key, and a schema-violating email through your client in a test environment and watched each branch run.
For the request and response shapes themselves, see the API docs. To build the schema your client sends, start with building a JSON Schema email parser.