Skip to content
Documentation

MailFrame API reference

Turn raw email into typed, schema-validated JSON. Start with the five-minute quickstart, then use the reference below — every endpoint ships with curl, TypeScript, and Python examples.

5-minute quickstart

From signup to your first typed JSON in five steps. You will request access, create an API key, pick a schema, send a raw email to /v1/parse, and validate the response.

  1. 1

    Request access & create a key

    MailFrame is rolling out to developers in batches. Request early access, then create an API key in the dashboard. The secret is shown once — store it in your secret manager, never in source control.

    # Store your key as an environment variable (never hard-code it)
    export MAILFRAME_API_KEY="mf_live_xxxxxxxxxxxxxxxx"
  2. 2

    Check connectivity

    Hit the public health endpoint to confirm you can reach the API before wiring in auth.

    curl https://api.mailframe.ai/health
    const res = await fetch("https://api.mailframe.ai/health", {
      method: "GET",
    });
    
    const data = await res.json();
    import os
    import requests
    
    res = requests.get(
        "https://api.mailframe.ai/health",
    )
    data = res.json()
  3. 3

    Pick a schema

    A schema is a JSON Schema document describing the fields you want. Use one from the schema library (like stripe_receipt) or define your own. MailFrame validates every extraction against it before returning.

    json
    {
      "title": "stripe_receipt",
      "type": "object",
      "required": ["amount_cents", "currency"],
      "properties": {
        "amount_cents": { "type": "integer" },
        "currency":     { "type": "string" },
        "card_last4":   { "type": "string", "pattern": "^[0-9]{4}$" }
      }
    }
  4. 4

    Run your first parse

    POST the raw RFC 822 MIME text and the schema name to /v1/parse. MailFrame answers the same request with the validated result.

    curl -X POST https://api.mailframe.ai/v1/parse \
      -H "Authorization: Bearer $MAILFRAME_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "schema_id": "stripe_receipt",
        "raw_mime": "From: billing@stripe.com\r\nTo: user@example.com\r\nSubject: Your Stripe Receipt\r\n\r\nThank you for your payment of $29.99."
      }'
    const 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_id": "stripe_receipt",
        "raw_mime": "From: billing@stripe.com\r\nTo: user@example.com\r\nSubject: Your Stripe Receipt\r\n\r\nThank you for your payment of $29.99."
      }),
    });
    
    const data = await res.json();
    import os
    import requests
    
    res = requests.post(
        "https://api.mailframe.ai/v1/parse",
        headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
        json={
            "schema_id": "stripe_receipt",
            "raw_mime": "From: billing@stripe.com\r\nTo: user@example.com\r\nSubject: Your Stripe Receipt\r\n\r\nThank you for your payment of $29.99."
        },
    )
    data = res.json()
  5. 5

    Validate the response & store it

    Check the HTTP status, then the parse status and validation_errors on the body. When the parse completed cleanly, the payload is already typed and schema-valid — write it straight to your database.

    const 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_id: "stripe_receipt",
        raw_mime: rawMimeText,
      }),
    });
    
    if (!res.ok) {
      throw new Error(`MailFrame error ${res.status}: ${await res.text()}`);
    }
    
    const { data, status, validation_errors = [] } = await res.json();
    
    // Typed and schema-validated already — check status, then store.
    if (status === "completed" && validation_errors.length === 0) {
      await receipts.insert(data);
    } else {
      console.warn("Parse status:", status, "errors:", validation_errors);
    }
    import os
    import requests
    
    res = requests.post(
        "https://api.mailframe.ai/v1/parse",
        headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
        json={
            "schema_id": "stripe_receipt",
            "raw_mime": raw_mime_text,
        },
    )
    res.raise_for_status()
    body = res.json()
    
    # Typed and schema-validated already — check status, then store.
    if body["status"] == "completed" and not body.get("validation_errors"):
        receipts.insert(body["data"])
    else:
        print("Parse status:", body["status"], "errors:", body.get("validation_errors"))

Authentication

All API requests are authenticated with a bearer token over HTTPS:

Authorization: Bearer $MAILFRAME_API_KEY
  • Create, name, scope, and rotate keys in the MailFrame dashboard. The secret is shown once at creation.
  • Keys are environment-scoped (for example mf_live_… and mf_test_…). Keep them out of source control and client-side code.
  • API-key management endpoints (/v1/api-keys) require a write-scoped API key. The onboarding endpoint (/v1/onboarding) uses a dashboard session instead — it is labelled accordingly below.

Base URL & conventions

The API base URL is https://api.mailframe.ai. Requests and responses are JSON. Errors use standard HTTP status codes with a JSON body:

json · error shape
{
  "error": {
    "type": "invalid_request",
    "message": "Unknown schema: stripe_receipt",
    "field": "schema_id",
    "request_id": "req_8f2a1c"
  }
}
Status Meaning
200 / 201Success.
400Invalid request — bad JSON, unknown schema, or malformed input.
401Missing or invalid API key.
422Parsed, but the result failed schema validation (see validation_errors).
429Rate or plan limit reached — back off and retry.
5xxServer error — safe to retry with backoff.

Core

The two endpoints you need to ship: a health probe and the synchronous parser.

GET /health Public

Service health

Liveness probe for the API. Returns 200 when the service is up. Safe to poll from uptime checks; no authentication required. A companion GET /health/ready reports readiness once the parse pipeline is accepting traffic.

curl https://api.mailframe.ai/health
const res = await fetch("https://api.mailframe.ai/health", {
  method: "GET",
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/health",
)
data = res.json()
200 response
{
  "status": "ok",
  "version": "1.x"
}
POST /v1/parse API key

Parse an email

The primary endpoint. POST a raw email as raw_mime (raw RFC 822 MIME text) along with a schema_id to extract against — or send an inline schema instead. MailFrame validates the extraction against your schema and returns typed JSON synchronously in the same HTTP response.

Direct POST of raw MIME is the fully supported path today. PDF/image input (the file field) and forwarding to a unique inbox address are on the roadmap (see Inbound, below).

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_id": "stripe_receipt",
    "raw_mime": "From: billing@stripe.com\r\nTo: user@example.com\r\nSubject: Your Stripe Receipt\r\n\r\nThank you for your payment of $29.99."
  }'
const 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_id": "stripe_receipt",
    "raw_mime": "From: billing@stripe.com\r\nTo: user@example.com\r\nSubject: Your Stripe Receipt\r\n\r\nThank you for your payment of $29.99."
  }),
});

const data = await res.json();
import os
import requests

res = requests.post(
    "https://api.mailframe.ai/v1/parse",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "schema_id": "stripe_receipt",
        "raw_mime": "From: billing@stripe.com\r\nTo: user@example.com\r\nSubject: Your Stripe Receipt\r\n\r\nThank you for your payment of $29.99."
    },
)
data = res.json()
200 response
{
  "id": "parse_8f2a1c",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "amount_cents": 2999,
    "currency": "usd",
    "card_last4": "4242"
  }
}

Schemas

Schemas are JSON Schema documents that describe the fields you want back. Validate every extraction against one before it reaches your database.

GET /v1/schemas API key

List schemas

List the custom schemas available in your tenant account — the schemas you have defined.

curl https://api.mailframe.ai/v1/schemas \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/schemas", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/schemas",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
200 response
[
  {
    "id": "sch_stripe",
    "tenant_id": "ten_7a6b",
    "name": "stripe_receipt",
    "description": "Stripe receipt totals and card details.",
    "schema_json": {
      "type": "object",
      "required": ["amount_cents", "currency"],
      "properties": {
        "amount_cents": { "type": "integer" },
        "currency": { "type": "string" }
      }
    },
    "version": 1,
    "is_active": true,
    "created_at": "2026-01-01T00:00:00Z",
    "updated_at": "2026-01-01T00:00:00Z"
  },
  {
    "id": "sch_a1b2",
    "tenant_id": "ten_7a6b",
    "name": "invoice_v1",
    "description": "",
    "schema_json": {
      "type": "object",
      "required": ["invoice_number", "total_cents"],
      "properties": {
        "invoice_number": { "type": "string" },
        "total_cents": { "type": "integer" }
      }
    },
    "version": 1,
    "is_active": true,
    "created_at": "2026-06-09T12:00:00Z",
    "updated_at": "2026-06-09T12:00:00Z"
  }
]
GET /v1/schemas/{id} API key

Get a schema

Fetch the full JSON Schema document for a single custom schema available in your tenant account so you can inspect the exact fields, types, and validation rules MailFrame enforces.

curl https://api.mailframe.ai/v1/schemas/stripe_receipt \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/schemas/stripe_receipt", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/schemas/stripe_receipt",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
200 response
{
  "id": "sch_stripe",
  "tenant_id": "ten_7a6b",
  "name": "stripe_receipt",
  "description": "Stripe receipt totals and card details.",
  "schema_json": {
    "type": "object",
    "required": ["amount_cents", "currency"],
    "properties": {
      "amount_cents": { "type": "integer" },
      "currency": { "type": "string" },
      "card_last4": { "type": "string", "pattern": "^[0-9]{4}$" }
    }
  },
  "version": 1,
  "is_active": true,
  "created_at": "2026-01-01T00:00:00Z",
  "updated_at": "2026-01-01T00:00:00Z"
}
POST /v1/schemas API key

Create a schema

Register a custom schema. Provide a name and a JSON Schema document as schema_json; MailFrame validates extractions against it on every parse.

curl -X POST https://api.mailframe.ai/v1/schemas \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice_v1",
    "schema_json": {
      "type": "object",
      "required": [
        "invoice_number",
        "total_cents"
      ],
      "properties": {
        "invoice_number": {
          "type": "string"
        },
        "total_cents": {
          "type": "integer"
        },
        "currency": {
          "type": "string"
        }
      }
    }
  }'
const res = await fetch("https://api.mailframe.ai/v1/schemas", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "invoice_v1",
    "schema_json": {
      "type": "object",
      "required": [
        "invoice_number",
        "total_cents"
      ],
      "properties": {
        "invoice_number": {
          "type": "string"
        },
        "total_cents": {
          "type": "integer"
        },
        "currency": {
          "type": "string"
        }
      }
    }
  }),
});

const data = await res.json();
import os
import requests

res = requests.post(
    "https://api.mailframe.ai/v1/schemas",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "name": "invoice_v1",
        "schema_json": {
            "type": "object",
            "required": [
                "invoice_number",
                "total_cents"
            ],
            "properties": {
                "invoice_number": {
                    "type": "string"
                },
                "total_cents": {
                    "type": "integer"
                },
                "currency": {
                    "type": "string"
                }
            }
        }
    },
)
data = res.json()
200 response
{
  "id": "sch_a1b2",
  "tenant_id": "ten_7a6b",
  "name": "invoice_v1",
  "description": "",
  "schema_json": {
    "type": "object",
    "required": ["invoice_number", "total_cents"],
    "properties": {
      "invoice_number": { "type": "string" },
      "total_cents": { "type": "integer" },
      "currency": { "type": "string" }
    }
  },
  "version": 1,
  "is_active": true,
  "created_at": "2026-06-09T12:00:00Z",
  "updated_at": "2026-06-09T12:00:00Z"
}
PUT /v1/schemas/{id} API key

Update a schema

Replace a custom schema you own. Provide the schema name and the full JSON Schema document as schema_json; subsequent parses validate against the updated definition.

curl -X PUT https://api.mailframe.ai/v1/schemas/invoice_v1 \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice_v1",
    "schema_json": {
      "type": "object",
      "required": [
        "invoice_number",
        "total_cents",
        "currency"
      ],
      "properties": {
        "invoice_number": {
          "type": "string"
        },
        "total_cents": {
          "type": "integer"
        },
        "currency": {
          "type": "string"
        }
      }
    }
  }'
const res = await fetch("https://api.mailframe.ai/v1/schemas/invoice_v1", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "invoice_v1",
    "schema_json": {
      "type": "object",
      "required": [
        "invoice_number",
        "total_cents",
        "currency"
      ],
      "properties": {
        "invoice_number": {
          "type": "string"
        },
        "total_cents": {
          "type": "integer"
        },
        "currency": {
          "type": "string"
        }
      }
    }
  }),
});

const data = await res.json();
import os
import requests

res = requests.put(
    "https://api.mailframe.ai/v1/schemas/invoice_v1",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "name": "invoice_v1",
        "schema_json": {
            "type": "object",
            "required": [
                "invoice_number",
                "total_cents",
                "currency"
            ],
            "properties": {
                "invoice_number": {
                    "type": "string"
                },
                "total_cents": {
                    "type": "integer"
                },
                "currency": {
                    "type": "string"
                }
            }
        }
    },
)
data = res.json()
200 response
{
  "id": "sch_a1b2",
  "tenant_id": "ten_7a6b",
  "name": "invoice_v1",
  "description": "",
  "schema_json": {
    "type": "object",
    "required": ["invoice_number", "total_cents", "currency"],
    "properties": {
      "invoice_number": { "type": "string" },
      "total_cents": { "type": "integer" },
      "currency": { "type": "string" }
    }
  },
  "version": 2,
  "is_active": true,
  "created_at": "2026-06-09T12:00:00Z",
  "updated_at": "2026-06-09T12:30:00Z"
}
DELETE /v1/schemas/{id} API key

Delete a schema

Delete a custom schema you own. Built-in library schemas cannot be deleted.

curl -X DELETE https://api.mailframe.ai/v1/schemas/invoice_v1 \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/schemas/invoice_v1", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.delete(
    "https://api.mailframe.ai/v1/schemas/invoice_v1",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()

Parse history & usage

Read back past parses for auditing and reconciliation, and check your usage against plan limits.

GET /v1/parses API key

List parses

Page through your recent parses. Useful for dashboards, reconciliation, and re-fetching results you did not persist at parse time.

curl https://api.mailframe.ai/v1/parses?limit=20 \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/parses?limit=20", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/parses?limit=20",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
200 response
{
  "data": [
    { "id": "parse_8f2a1c", "schema_id": "stripe_receipt", "status": "completed", "created_at": "2026-06-09T12:00:00Z" }
  ],
  "has_more": false
}
GET /v1/parses/{id} API key

Get a parse

Retrieve a single parse by id, including the typed data payload, status, and any schema validation errors.

curl https://api.mailframe.ai/v1/parses/parse_8f2a1c \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/parses/parse_8f2a1c", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/parses/parse_8f2a1c",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
GET /v1/usage/stats API key

Usage stats

Return your parse volume for the current billing period and your plan limits. Use it to surface usage in your own UI or to alert before you hit a cap.

curl https://api.mailframe.ai/v1/usage/stats \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/usage/stats", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/usage/stats",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
200 response
{
  "period": "2026-06",
  "parses_used": 1240,
  "parses_included": 5000
}

API keys & onboarding

Manage the API keys on your account programmatically — these endpoints are authenticated with an API key — or interactively in the MailFrame dashboard. Onboarding runs from the dashboard with a signed-in session.

GET /v1/api-keys API key

List API keys

List the API keys on your account with their prefixes, scope, and last-used timestamps. Secret key material is only ever shown once, at creation time — it is never returned here.

You can also create and rotate keys interactively at app.mailframe.ai.

curl https://api.mailframe.ai/v1/api-keys \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/api-keys", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/api-keys",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
200 response
[
  {
    "id": "key_a1b2",
    "name": "production",
    "prefix": "mf_live_a1b2",
    "scope": "read-write",
    "created_at": "2026-06-01T09:00:00Z",
    "last_used_at": "2026-06-09T11:00:00Z"
  }
]
POST /v1/api-keys API key

Create an API key

Mint a new API key. The full secret is returned exactly once in this response and never again — store it in your secret manager immediately.

curl -X POST https://api.mailframe.ai/v1/api-keys \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production",
    "scope": "read-write"
  }'
const res = await fetch("https://api.mailframe.ai/v1/api-keys", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "production",
    "scope": "read-write"
  }),
});

const data = await res.json();
import os
import requests

res = requests.post(
    "https://api.mailframe.ai/v1/api-keys",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "name": "production",
        "scope": "read-write"
    },
)
data = res.json()
200 response
{
  "id": "key_a1b2",
  "name": "production",
  "prefix": "mf_live_a1b2",
  "scope": "read-write",
  "key": "mf_live_********************",
  "created_at": "2026-06-09T12:00:00Z",
  "last_used_at": null
}
DELETE /v1/api-keys/{id} API key

Revoke an API key

Revoke an API key by id. The key stops working immediately — requests using it begin returning 401.

curl -X DELETE https://api.mailframe.ai/v1/api-keys/key_a1b2 \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/api-keys/key_a1b2", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.delete(
    "https://api.mailframe.ai/v1/api-keys/key_a1b2",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
POST /v1/onboarding Dashboard session

Complete onboarding

Completes the signed-in account's onboarding and provisions initial defaults after sign-up. Powers the dashboard's getting-started flow and is authenticated with a Supabase dashboard session, not an API key.

curl -X POST https://api.mailframe.ai/v1/onboarding
const res = await fetch("https://api.mailframe.ai/v1/onboarding", {
  method: "POST",
});

const data = await res.json();
import os
import requests

res = requests.post(
    "https://api.mailframe.ai/v1/onboarding",
)
data = res.json()

Webhooks

Asynchronous, signed delivery of parse results to your own endpoint. Each account has a single webhook configuration. Available in early access — the request shapes below are stable but may change before general availability. Early-access endpoints require a write-scoped API key.

GET /v1/webhooks Early access

Get webhook configuration

Return your account's webhook configuration — the destination URL, whether delivery is active, and when it was created and last updated. Webhooks are configured per account, not per resource. The signing secret you provided is stored but never returned here.

curl https://api.mailframe.ai/v1/webhooks \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/webhooks", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
200 response
{
  "data": {
    "id": "wh_a1b2",
    "tenant_id": "ten_7a6b",
    "url": "https://example.com/hooks/mailframe",
    "is_active": true,
    "created_at": "2026-06-01T09:00:00Z",
    "updated_at": "2026-06-09T11:00:00Z"
  }
}
PUT /v1/webhooks Early access

Create or update webhook configuration

Set the HTTPS endpoint that receives parse results, along with the signing secret MailFrame uses to sign deliveries. You provide the signing secret in the request and MailFrame stores it; it is never echoed back in this or the GET response. MailFrame signs every delivery with an HMAC over the raw body so you can verify authenticity (see Verifying webhook signatures, below).

curl -X PUT https://api.mailframe.ai/v1/webhooks \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/mailframe",
    "signing_secret": "whsec_..."
  }'
const res = await fetch("https://api.mailframe.ai/v1/webhooks", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "url": "https://example.com/hooks/mailframe",
    "signing_secret": "whsec_..."
  }),
});

const data = await res.json();
import os
import requests

res = requests.put(
    "https://api.mailframe.ai/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "url": "https://example.com/hooks/mailframe",
        "signing_secret": "whsec_..."
    },
)
data = res.json()
200 response
{
  "data": {
    "id": "wh_a1b2",
    "tenant_id": "ten_7a6b",
    "url": "https://example.com/hooks/mailframe",
    "is_active": true,
    "created_at": "2026-06-01T09:00:00Z",
    "updated_at": "2026-06-09T11:00:00Z"
  }
}
DELETE /v1/webhooks Early access

Delete webhook configuration

Remove your account's webhook configuration. MailFrame stops delivering events; you can re-create it at any time by providing your endpoint URL and signing secret again.

curl -X DELETE https://api.mailframe.ai/v1/webhooks \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/webhooks", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.delete(
    "https://api.mailframe.ai/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
GET /v1/webhooks/attempts Early access

List delivery attempts

Inspect the delivery log for your webhook: response codes, timing, and retry state for each attempt. Use it to debug an endpoint that is returning non-2xx.

curl https://api.mailframe.ai/v1/webhooks/attempts?limit=20 \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/webhooks/attempts?limit=20", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/webhooks/attempts?limit=20",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
GET /v1/webhooks/dead-letter Early access

List dead letters

After retries are exhausted, undelivered events move to the dead-letter queue. List them here to see what your endpoint missed while it was down.

curl https://api.mailframe.ai/v1/webhooks/dead-letter \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/webhooks/dead-letter", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/webhooks/dead-letter",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
POST /v1/webhooks/dead-letter/{id}/replay Early access

Replay a dead letter

Re-deliver a dead-lettered event once your endpoint is healthy again. The replayed delivery is signed exactly like the original.

curl -X POST https://api.mailframe.ai/v1/webhooks/dead-letter/dl_9f8e/replay \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/webhooks/dead-letter/dl_9f8e/replay", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.post(
    "https://api.mailframe.ai/v1/webhooks/dead-letter/dl_9f8e/replay",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()

Inboxes & inbound

Forward email to a unique MailFrame address instead of POSTing it yourself. Forwarded mail is received internally on MailFrame's provider endpoint (POST /v1/inbound, provider-authenticated) — you never call it directly. Available in early access — direct POST to /v1/parse remains the supported path today. Early-access endpoints require a write-scoped API key.

GET /v1/inboxes Early access

List inboxes

List your inbound addresses. Each inbox maps incoming mail to a schema, so forwarded email is parsed and delivered to your account's webhook automatically.

curl https://api.mailframe.ai/v1/inboxes \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/inboxes", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.get(
    "https://api.mailframe.ai/v1/inboxes",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()
POST /v1/inboxes Early access

Create an inbox

Provision an inbound address on a domain you control, optionally with a fixed local part and a default schema. Forward (or auto-forward) matching email to it and MailFrame parses each message and delivers the typed result to your configured webhook.

curl -X POST https://api.mailframe.ai/v1/inboxes \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "inbound.example.com",
    "local_part": "receipts",
    "default_schema": {
      "type": "object",
      "required": [
        "amount_cents",
        "currency"
      ],
      "properties": {
        "amount_cents": {
          "type": "integer"
        },
        "currency": {
          "type": "string"
        }
      }
    }
  }'
const res = await fetch("https://api.mailframe.ai/v1/inboxes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "domain": "inbound.example.com",
    "local_part": "receipts",
    "default_schema": {
      "type": "object",
      "required": [
        "amount_cents",
        "currency"
      ],
      "properties": {
        "amount_cents": {
          "type": "integer"
        },
        "currency": {
          "type": "string"
        }
      }
    }
  }),
});

const data = await res.json();
import os
import requests

res = requests.post(
    "https://api.mailframe.ai/v1/inboxes",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "domain": "inbound.example.com",
        "local_part": "receipts",
        "default_schema": {
            "type": "object",
            "required": [
                "amount_cents",
                "currency"
            ],
            "properties": {
                "amount_cents": {
                    "type": "integer"
                },
                "currency": {
                    "type": "string"
                }
            }
        }
    },
)
data = res.json()
200 response
{
  "data": {
    "id": "ibx_7a6b",
    "email_address": "receipts@inbound.example.com",
    "mailbox_hash": "receipts",
    "default_schema": {
      "type": "object",
      "required": ["amount_cents", "currency"],
      "properties": {
        "amount_cents": { "type": "integer" },
        "currency": { "type": "string" }
      }
    },
    "webhook_url": null,
    "is_active": true,
    "created_at": "2026-06-09T12:00:00Z",
    "updated_at": "2026-06-09T12:00:00Z"
  }
}
PATCH /v1/inboxes/{id} Early access

Update an inbox

Update an inbox's delivery webhook URL. Mail received after the update is delivered to the new endpoint; the inbox's address and default schema are fixed at creation.

curl -X PATCH https://api.mailframe.ai/v1/inboxes/ibx_7a6b \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://example.com/hooks/mailframe"
  }'
const res = await fetch("https://api.mailframe.ai/v1/inboxes/ibx_7a6b", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "webhook_url": "https://example.com/hooks/mailframe"
  }),
});

const data = await res.json();
import os
import requests

res = requests.patch(
    "https://api.mailframe.ai/v1/inboxes/ibx_7a6b",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
    json={
        "webhook_url": "https://example.com/hooks/mailframe"
    },
)
data = res.json()
DELETE /v1/inboxes/{id} Early access

Delete an inbox

Release an inbound address. Mail sent to it after deletion is rejected.

curl -X DELETE https://api.mailframe.ai/v1/inboxes/ibx_7a6b \
  -H "Authorization: Bearer $MAILFRAME_API_KEY"
const res = await fetch("https://api.mailframe.ai/v1/inboxes/ibx_7a6b", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
  },
});

const data = await res.json();
import os
import requests

res = requests.delete(
    "https://api.mailframe.ai/v1/inboxes/ibx_7a6b",
    headers={"Authorization": f"Bearer {os.environ['MAILFRAME_API_KEY']}"},
)
data = res.json()

Verifying webhook signatures

Every webhook delivery is signed so you can confirm it genuinely came from MailFrame and was not tampered with in transit. Each request carries these headers:

Header Description
X-MailFrame-SignatureHex HMAC-SHA256 of ${timestamp}.${rawBody}.
X-MailFrame-TimestampUnix seconds when the delivery was signed. Reject if too old.
X-MailFrame-EventEvent type, e.g. parse.completed.

Recompute the HMAC over the raw request body (not re-serialized JSON) with your endpoint's signing secret, and compare it to the header in constant time. Reject deliveries whose timestamp is outside a tolerance window to blunt replay attacks.

import crypto from "node:crypto";

// MailFrame signs each delivery with an HMAC-SHA256 over
// "${timestamp}.${rawBody}" using your endpoint's signing secret.
// Headers: X-MailFrame-Signature, X-MailFrame-Timestamp, X-MailFrame-Event.
export function verifyMailFrame(
  rawBody: string,
  headers: Record<string, string>,
  signingSecret: string,
  toleranceSeconds = 300,
): boolean {
  const signature = headers["x-mailframe-signature"];
  const timestamp = headers["x-mailframe-timestamp"];
  if (!signature || !timestamp) return false;

  // Reject stale deliveries to blunt replay attacks.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (Number.isNaN(age) || age > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // Constant-time comparison — never use === on secrets.
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time

# MailFrame signs each delivery with an HMAC-SHA256 over
# f"{timestamp}.{raw_body}" using your endpoint's signing secret.
# Headers: X-MailFrame-Signature, X-MailFrame-Timestamp, X-MailFrame-Event.
def verify_mailframe(raw_body: bytes, headers, signing_secret: str, tolerance_seconds: int = 300) -> bool:
    signature = headers.get("X-MailFrame-Signature")
    timestamp = headers.get("X-MailFrame-Timestamp")
    if not signature or not timestamp:
        return False

    # Reject stale deliveries to blunt replay attacks.
    try:
        if abs(time.time() - int(timestamp)) > tolerance_seconds:
            return False
    except ValueError:
        return False

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(signing_secret.encode(), signed_payload, hashlib.sha256).hexdigest()

    # Constant-time comparison — never use == on secrets.
    return hmac.compare_digest(expected, signature)

Wiring it into an Express handler — note that you must read the raw body before any JSON parser runs:

import express from "express";
import { verifyMailFrame } from "./verify-mailframe";

const app = express();

// Capture the RAW body — signatures are computed over bytes, not parsed JSON.
app.post(
  "/hooks/mailframe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const ok = verifyMailFrame(
      rawBody,
      req.headers as Record<string, string>,
      process.env.MAILFRAME_WEBHOOK_SECRET!,
    );
    if (!ok) return res.status(400).send("invalid signature");

    const event = JSON.parse(rawBody);
    // Acknowledge fast (2xx), then process out of band.
    res.sendStatus(200);
    void handleEvent(event);
  },
);

The signing secret (whsec_…) is the value you provide when you configure your webhook and is distinct from your API key. Store it as MAILFRAME_WEBHOOK_SECRET. Webhook delivery is in early access.

Status & uptime

Live component status, the incident policy, and update history are on the MailFrame status page (also served at status.mailframe.ai). For programmatic checks, poll GET /health for liveness, or GET /health/ready once the parse pipeline is accepting traffic.

Frequently asked questions

What input can I send to MailFrame today?
Today you POST raw RFC 822 MIME text directly to /v1/parse and get typed JSON back synchronously. PDF and image input, plus forwarding to a unique inbox address and asynchronous webhook delivery, are in early access — direct POST of raw email is the fully supported path right now. You can try a live parse in the browser without an API key.
How do I authenticate?
Send your API key as a bearer token: Authorization: Bearer $MAILFRAME_API_KEY. Create and rotate keys in the MailFrame dashboard. The /v1/api-keys REST endpoints also work for programmatic key management, but they require a write-scoped API key to call. The /v1/onboarding endpoint uses a dashboard session instead of an API key.
How are results delivered?
MailFrame returns the validated result synchronously in the response to your POST /v1/parse request — typed JSON that matches your schema, along with the parse status and any schema validation errors. Asynchronous, signed webhook delivery is available in early access.
How do I verify a webhook came from MailFrame?
Each delivery carries X-MailFrame-Signature and X-MailFrame-Timestamp headers. Compute an HMAC-SHA256 over the signed payload with your endpoint's signing secret and compare it in constant time, rejecting stale deliveries. See the verification guide for ready-to-use TypeScript and Python.

Get your API key

MailFrame is rolling out to developers in batches. Request early access and we'll send your keys when your spot opens.