Skip to content
api schema quickstart developers email-to-json

Parse plain-text and email-like input with MailFrame's /v1/parse

Test MailFrame without a real inbox: send a simple email-like text string and an inline JSON Schema to POST /v1/parse and get typed, validated JSON back.

MailFrame Team

MailFrame’s shipped path is a single endpoint. You POST /v1/parse with the raw email in raw_mime and a JSON Schema in schema, and you get typed, schema-validated JSON back synchronously in the same HTTP response.

The part that surprises people on their first call: raw_mime does not have to be a real RFC 822 message pulled from a mail server. A plain-text string that looks like an email is enough. That means you can try the full extraction pipeline from a terminal in under a minute, with no real inbox, no saved schema, and no base64 encoding.

This post walks through exactly that: the smallest request that exercises the real API end to end.

What counts as email-like input?

raw_mime accepts raw RFC 822 MIME text: headers, a blank line, then the body. Full messages with multipart bodies and encoded attachments work. But the parser does not reject input just because it is short or missing most headers. A single Subject: line, a blank line, and a few lines of body text is a valid message:

Subject: Your receipt from Acme Coffee

Thanks for your order on October 26, 2026.
Total charged: $29.99

That is the whole input. MailFrame treats the headers as headers and the rest as the body, then hands the normalized text to the extraction step.

Define a tiny inline schema

The schema field takes a standard JSON Schema object. You describe the fields you want back, their types, and any constraints. The extraction is validated against this contract before it is returned.

Here is a three-field schema for the receipt above:

{
  "type": "object",
  "required": ["merchant", "total_amount", "order_date"],
  "properties": {
    "merchant": { "type": "string" },
    "total_amount": { "type": "number", "minimum": 0 },
    "order_date": { "type": "string", "format": "date" }
  }
}

Two details matter here. total_amount is typed as a number, so you get 29.99 back rather than the string "$29.99". And order_date uses the date format, so the model must normalize “October 26, 2026” to 2026-10-26 or the field fails validation.

Send the request

Put both pieces in one JSON body. The newlines in raw_mime are written as \n inside the JSON string:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "schema": {
      "type": "object",
      "required": ["merchant", "total_amount", "order_date"],
      "properties": {
        "merchant": { "type": "string" },
        "total_amount": { "type": "number", "minimum": 0 },
        "order_date": { "type": "string", "format": "date" }
      }
    },
    "raw_mime": "Subject: Your receipt from Acme Coffee\n\nThanks for your order on October 26, 2026.\nTotal charged: $29.99\n"
  }'

No schema registration, no setup beyond an API key. The schema travels with the request.

Read the response

A clean parse returns HTTP 200 with the parse id, its status, and the extracted object under data. The response below is illustrative: the shape is what the API returns, but the id and the extracted values depend on the model’s run against your input:

{
  "id": "parse_3c91ab",
  "status": "completed",
  "data": {
    "merchant": "Acme Coffee",
    "total_amount": 29.99,
    "order_date": "2026-10-26"
  }
}

A schema mismatch does not fail the request. When the extracted output violates your schema, the API still returns HTTP 200 with status: "completed", the parse id, and the best-effort data, plus a validation_errors array describing what did not match. The array is omitted from the body when there is nothing to report, and when present each entry is an object with a field and a message, not a bare string. Treat a missing array the same as an empty one, and check it before you trust data.

HTTP 422 is reserved for a different case: the extraction step itself fails and cannot produce any output to validate. That response carries no parse id or data. Instead it uses the same error envelope as every other failure: a body whose single top-level error object holds type, message, and request_id. A bad API key, an exhausted quota, or malformed JSON use that envelope too, with their own status codes. So the HTTP status is your first routing signal: a 2xx means you have a parse result to inspect, including any validation_errors, and the error envelope is the only shape you will see on a non-2xx response.

The helper below is self-contained and meant to run server-side in Node, where it reads your API key from the MAILFRAME_API_KEY environment variable and fails fast if it is missing. It sends the request, branches on the status code first, and returns a plain result object. What you do with each outcome, such as inserting a row or queuing the parse for review, is up to your application:

interface ValidationError {
  field: string;
  message: string;
}

interface ParseResult {
  id: string;
  status: string;
  data: Record<string, unknown>;
  validation_errors?: ValidationError[];
}

interface ErrorEnvelope {
  error: { type: string; message: string; request_id: string };
}

type ReceiptOutcome =
  | { kind: "valid"; id: string; data: Record<string, unknown> }
  | { kind: "needs_review"; id: string; data: Record<string, unknown>; validationErrors: ValidationError[] }
  | { kind: "request_error"; httpStatus: number; type: string; message: string; requestId: string };

async function parseReceiptText(rawText: string, schema: object): Promise<ReceiptOutcome> {
  const apiKey = process.env.MAILFRAME_API_KEY;
  if (!apiKey) throw new Error("MAILFRAME_API_KEY is not set");

  const res = await fetch("https://api.mailframe.ai/v1/parse", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + apiKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ schema, raw_mime: rawText }),
  });

  if (!res.ok) {
    // Every failure, including a 422 extraction failure, is an error envelope.
    // There is no parse id or data to inspect here.
    const body = (await res.json()) as ErrorEnvelope;
    return {
      kind: "request_error",
      httpStatus: res.status,
      type: body.error.type,
      message: body.error.message,
      requestId: body.error.request_id,
    };
  }

  const body = (await res.json()) as ParseResult;
  const validationErrors = body.validation_errors ?? [];

  if (body.status === "completed" && validationErrors.length === 0) {
    // Typed and schema-valid: safe to store directly.
    return { kind: "valid", id: body.id, data: body.data };
  }

  // A 200 that still reports something worth a human look.
  return { kind: "needs_review", id: body.id, data: body.data, validationErrors };
}

Because the schema contract is enforced server-side, the happy path never has to re-check types or formats. Everything that comes back as valid already matches the schema you sent.

When to use raw MIME instead

Email-like text is the fastest way to prove the pipeline works and to iterate on a schema. Once you move to production traffic, send the full raw MIME message your mail provider gives you. You will want that when:

  • You need real From, To, Date, or Message-ID headers in the extraction context.
  • The email is multipart with both text/plain and text/html bodies.
  • The message carries attachments or other encoded parts. MailFrame separates them from the readable body, so encoded blobs never pollute the text handed to extraction.
  • The message contains quoted reply threads and you want the parser to see the whole exchange.

Nothing else changes. The schema field, the response shape, and the error envelope are identical. Only the string in raw_mime grows from a few lines to a full message.

Next steps

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