Skip to content
api error-handling retries timeouts email-parsing

Handling Errors and Timeouts from POST /v1/parse

A client guide to POST /v1/parse: which statuses to retry, how to handle validation_errors, and how to log request_id.

MailFrame Team

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.

  1. The request fails. You get a non-2xx status and an error envelope. No parse result exists.
  2. The request succeeds but the result is not clean. You get a 2xx status, the typed data, and a non-empty validation_errors array. 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"
  }
}
StatusDocumented meaningYour action
200 / 201Success; typed data plus any validation_errorsCheck status and validation_errors before storing
400Bad JSON, unknown schema, or malformed inputFix the request. Do not retry unchanged
401Missing or invalid API keyFix the key. Do not retry unchanged
422Extraction failed; no parse result was producedDo not blind-retry. Route the email for review
429Rate or plan limit reachedBack off and retry, or stop if the plan limit is the cause
5xxServer error; documented as safe to retry with backoffRetry 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_errors never 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 status and validation_errors.
  • 400 and 401 are never retried unchanged.
  • 422 and non-empty validation_errors route 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_id is 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.

Turn your inbox into an API

MailFrame parses raw email into typed JSON against your own schema — PDF and image input are planned. Request early developer access.

Request early access