{
  "openapi": "3.0.3",
  "info": {
    "title": "Mail A Letter API",
    "version": "1.0.0",
    "description": "Send physical mail with an HTTP request. Upload a PDF, give us the recipient's address, and we print, stuff, stamp, and mail it.\n\n**Billing** is prepaid: every letter draws from your MailBank balance. There is no card payment through the API - if your balance cannot cover a letter, the API returns `402`, saves the letter as a draft, and you top up on the website.\n\n**Test mode**: authenticate with your sandbox key (`mal_test_...`) and every request runs the full validation and pricing pipeline but nothing is stored, charged, or mailed. Statuses on test letters progress automatically (processing → printing after 5 minutes → mailed after 30).\n\n**Limits**: PDF only, 20MB max, US Letter or A4 (portrait or landscape), 120 requests/minute per key.",
    "contact": {
      "email": "support@mailaletter.com"
    }
  },
  "servers": [
    {
      "url": "https://www.mailaletter.com/api/v1",
      "description": "Production"
    }
  ],
  "security": [
    { "bearerKey": [] },
    { "headerKey": [] }
  ],
  "paths": {
    "/letters": {
      "post": {
        "summary": "Submit a letter for mailing",
        "description": "Validates the PDF entirely before storing anything: page count, page size (US Letter/A4), encryption, and corruption checks run on the uploaded bytes. On success with a live key the letter is priced from your rate table and charged to your MailBank. With a sandbox key you get the identical response shape and real pricing, but nothing is stored or charged.\n\nSandbox-only failure triggers: recipient `postal_code` `00000` always returns `invalid_address`; recipient `first_name` `TEST-INSUFFICIENT-FUNDS` always returns `402`.",
        "operationId": "submitLetter",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SubmitLetterRequest" },
              "example": {
                "file_name": "welcome-letter.pdf",
                "file": "JVBERi0xLjQK... (base64 of the PDF)",
                "recipient": {
                  "first_name": "Jane",
                  "last_name": "Doe",
                  "company": "Acme Corp",
                  "address1": "500 Union St",
                  "address2": "Suite 100",
                  "city": "Seattle",
                  "state": "WA",
                  "postal_code": "98101",
                  "country": "US"
                },
                "options": {
                  "return_envelope": false,
                  "delivery_service": "first_class"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Letter accepted and (live mode) charged to your MailBank.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Letter" },
                "example": {
                  "id": "1024042",
                  "test_mode": false,
                  "status": "processing",
                  "pages": 2,
                  "cost": 1.87,
                  "tax": 0.0,
                  "total": 1.87,
                  "balance_remaining": 48.13
                }
              }
            }
          },
          "400": {
            "description": "Invalid request: `invalid_json`, `invalid_request`, `invalid_pdf`, `encrypted_pdf`, `empty_pdf`, `unsupported_page_size`, or `invalid_address`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": { "code": "unsupported_page_size", "message": "Pages must be US Letter (8.5x11in) or A4 (portrait or landscape)." } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": {
            "description": "MailBank balance cannot cover the letter. The letter is saved as a draft (`draft_id`) you can pay for on the website after topping up.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/InsufficientFundsError" },
                "example": {
                  "error": {
                    "code": "insufficient_funds",
                    "message": "Your MailBank balance does not cover this letter. It was saved as a draft you can pay for on the website after topping up.",
                    "balance": 0.42,
                    "required": 1.87,
                    "top_up_url": "https://www.mailaletter.com/MailBank.aspx",
                    "draft_id": "1024043"
                  }
                }
              }
            }
          },
          "413": {
            "description": "File exceeds the 20MB limit (`file_too_large`).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "415": {
            "description": "Only PDF files are accepted (`unsupported_media_type`).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/letters/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The letter id returned by POST /letters. Live ids are numeric; test-mode ids start with `test_`.",
          "schema": { "type": "string" }
        }
      ],
      "get": {
        "summary": "Get a letter's status",
        "operationId": "getLetter",
        "responses": {
          "200": {
            "description": "Current status. `fulfillment_provider` appears once the letter is in the carrier pipeline (printing onward); `tracking_number` appears only for letters mailed with a `delivery_service` other than `first_class`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Letter" },
                "example": { "id": "1024042", "test_mode": false, "status": "mailed", "pages": 2, "cost": 1.87, "tax": 0.0, "total": 1.87, "fulfillment_provider": "USPS", "tracking_number": "9407300000000000000001" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/balance": {
      "get": {
        "summary": "Get your MailBank balance",
        "operationId": "getBalance",
        "responses": {
          "200": {
            "description": "Current prepaid balance.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Balance" },
                "example": { "balance": 48.13, "currency": "USD" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your API key from the Account page, e.g. `Authorization: Bearer mal_live_...`. A `mal_test_...` key switches the request into test mode."
      },
      "headerKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "Alternative to the Authorization header."
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or unknown API key, or the API is not enabled on the account (`unauthorized`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "No letter with that id on this account (`not_found`).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "More than 120 requests in a minute (`rate_limited`). Honor the Retry-After header.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "SubmitLetterRequest": {
        "type": "object",
        "required": ["file_name", "file", "recipient"],
        "properties": {
          "file_name": {
            "type": "string",
            "description": "Original file name; must end in .pdf.",
            "example": "welcome-letter.pdf"
          },
          "file": {
            "type": "string",
            "format": "byte",
            "description": "The PDF, base64-encoded. 20MB max (before encoding). Pages must be US Letter or A4."
          },
          "recipient": { "$ref": "#/components/schemas/Recipient" },
          "options": { "$ref": "#/components/schemas/LetterOptions" }
        }
      },
      "Recipient": {
        "type": "object",
        "required": ["address1", "city", "country"],
        "description": "A first_name/last_name (combined max 40 chars) or a company (max 40 chars) is required. US and Canadian addresses additionally need state (state/province) and postal_code. Two street lines maximum - there is no address3; a third line cannot be printed by the fulfillment pipeline.",
        "properties": {
          "first_name": { "type": "string", "example": "Jane", "description": "Combined with last_name onto the envelope name line; combined max 40 characters. Optional when company is given." },
          "last_name": { "type": "string", "example": "Doe", "description": "See first_name." },
          "company": { "type": "string", "maxLength": 40, "example": "Acme Corp", "description": "Optional when a first_name/last_name is given. Max 40 characters." },
          "address1": { "type": "string", "example": "500 Union St", "description": "Street address. Required." },
          "address2": { "type": "string", "example": "Suite 100", "description": "Optional second street line (apartment, suite, unit)." },
          "city": { "type": "string", "example": "Seattle", "description": "Required." },
          "state": { "type": "string", "example": "WA", "description": "Required for US and Canadian addresses (2-letter US state, or Canadian province)." },
          "postal_code": { "type": "string", "example": "98101", "description": "Required for US and Canadian addresses." },
          "country": {
            "type": "string",
            "description": "Required. 2-letter ISO code (e.g. \"US\") or full country name.",
            "example": "US"
          }
        }
      },
      "LetterOptions": {
        "type": "object",
        "description": "return_envelope defaults to false. delivery_service picks exactly one service per letter and defaults to first_class; tracking, certified and restricted_delivery are US destinations only - requesting them for an international address returns 400 invalid_request.",
        "properties": {
          "return_envelope": { "type": "boolean", "default": false },
          "delivery_service": {
            "type": "string",
            "enum": ["first_class", "tracking", "certified", "restricted_delivery", "express"],
            "default": "first_class"
          }
        }
      },
      "Letter": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "example": "1024042" },
          "test_mode": { "type": "boolean" },
          "status": {
            "type": "string",
            "enum": ["processing", "pending_payment", "address_verification", "printing", "printed", "awaiting_delivery", "mailed", "canceled", "errored", "draft"]
          },
          "pages": { "type": "integer" },
          "cost": { "type": "number", "format": "decimal" },
          "tax": { "type": "number", "format": "decimal", "description": "9.5% for letters to Washington state addresses; 0 otherwise." },
          "total": { "type": "number", "format": "decimal" },
          "balance_remaining": { "type": "number", "format": "decimal", "description": "MailBank balance after this call. Only on POST responses." },
          "fulfillment_provider": { "type": "string", "description": "The carrier delivering the letter, present once it is in the carrier pipeline (printing onward). Always \"USPS\" today.", "example": "USPS" },
          "tracking_number": { "type": "string", "description": "USPS tracking number; present only for letters mailed with a delivery_service other than first_class." },
          "message": { "type": "string" }
        }
      },
      "Balance": {
        "type": "object",
        "properties": {
          "balance": { "type": "number", "format": "decimal" },
          "currency": { "type": "string", "example": "USD" }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": ["unauthorized", "rate_limited", "https_required", "invalid_json", "invalid_request", "unsupported_media_type", "file_too_large", "invalid_pdf", "encrypted_pdf", "empty_pdf", "unsupported_page_size", "invalid_address", "insufficient_funds", "not_found", "method_not_allowed", "internal_error"]
              },
              "message": { "type": "string" }
            }
          }
        }
      },
      "InsufficientFundsError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "string", "example": "insufficient_funds" },
              "message": { "type": "string" },
              "balance": { "type": "number", "format": "decimal" },
              "required": { "type": "number", "format": "decimal" },
              "top_up_url": { "type": "string" },
              "draft_id": { "type": "string", "description": "The saved draft's id (live mode only)." }
            }
          }
        }
      }
    }
  }
}
