Developer Reference

API Documentation

Developer reference for /api/v1/* endpoints + webhooks + error catalog. ~1695 lines.

Premier Letters API Documentation

Table of Contents

  1. Overview
  2. Authentication
  3. Rate Limiting
  4. Agent Connect
  5. Test Mode
  6. Return Address
  7. Printed Output
  8. Cards & Postcards
  9. Long Messages & Pages
  10. Idempotency
  11. Balance
  12. Endpoint Reference
  13. MCP Server & Agent Discovery
  14. Webhooks
  15. Error Codes
  16. Changelog

Overview

The Premier Letters API enables programmatic access to order creation, management, and tracking for handwritten letter services. All API requests are made to the base URL:

https://premierletters.com/api/v1/

Versioning

All endpoints are prefixed with /v1/, which represents a stable contract. Breaking changes will only be introduced in a new version (e.g., /v2/). Within the /v1/ namespace, backward-compatible additions are permitted.

Content Type

All requests must include the Content-Type: application/json header.

curl -X POST https://premierletters.com/api/v1/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY"

HTTP Status Codes

StatusMeaning
200Success — request completed as expected.
201Created — a new resource was successfully created.
202Accepted — request accepted for processing (e.g., draft order).
204No Content — successful request with no response body.
400Bad Request — validation or malformed request.
402Payment Required — no usable payment method, a failed charge, or insufficient prepaid balance or credits.
404Not Found — resource does not exist.
409Conflict — operation cannot be performed in current state.
422Unprocessable Entity — validation failed (see validation_failed error).
429Too Many Requests — rate limit exceeded.
500Internal Server Error — server error.

Authentication

API Key Authentication

All API requests (except public endpoints) must be authenticated using an API key in the Authorization header:

Authorization: Bearer pl_live_<key_suffix>

Keys come in two modes: live (pl_live_...) create and charge real orders; test (pl_test_...) run the same validation and pricing but never create, charge or send anything (see Test Mode). An AI assistant can also obtain a key without the user copying one by hand (see Agent Connect).

Generating an API Key

  1. Log in to your Premier Letters account
  2. Navigate to Settings → API Keys
  3. Click "Generate New Key"
  4. Copy the full key (displayed only once)
  5. Store securely — if lost, you must generate a new key

Key Storage

API keys are stored in the api_keys table with:

  • key_hash: bcrypt hash of the full key (never transmitted)
  • key_prefix: first 12 characters for display (e.g., pl_live_abc...)
  • is_active: whether the key can be used (deactivate instead of deleting)
  • last_used_at: timestamp of last successful authentication

Example Request

curl -X GET https://premierletters.com/api/v1/auth/verify \
  -H "Authorization: Bearer pl_live_abcdef123456"

Webhook Signature Verification

Inbound webhooks you receive (from Stripe, inbound email, etc.) are signed using HMAC-SHA256:

X-Signature-256: sha256=<hmac_hex>

Verify by computing:

hmac_sha256(webhook_secret, request_body_bytes)

Compare the computed hash to the signature header. Reject if they don't match.


Rate Limiting

The API enforces rate limits per API key (in-memory, per server instance) to ensure fair usage. Limits are per endpoint, per minute, plus daily caps:

EndpointPer-minute limit
/api/v1/orders* (POST /api/v1/orders, and the GET order list, detail and tracking)10
POST /api/v1/validate100
/api/v1/webhooks*30
Everything else (/api/v1/*, /api/mcp)60 (default)

Limits are counted per key and per path, so GET and POST /api/v1/orders share one bucket.

The unauthenticated agent connect endpoints are limited by IP instead: POST /api/v1/agent/connect allows 5 requests per IP per hour (and 3 approval emails per address per hour); polling GET /api/v1/agent/connect/{id} allows 30 per minute. Balance top-ups are limited to 10 per account per hour.

Daily caps, per key: 500 orders/day, 5000 total requests/day.

The MCP server's create_order tool shares the same 10/min + daily-order limits as POST /api/v1/orders (same key, same bucket).

  • Response headers:
    • X-RateLimit-Limit: limit per window
    • X-RateLimit-Remaining: requests remaining
    • X-RateLimit-Reset: Unix timestamp of reset
    • X-RateLimit-Daily-Limit / X-RateLimit-Daily-Remaining / X-RateLimit-Daily-Reset: daily cap headers

When rate-limited, the API responds with HTTP 429:

{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Please wait before retrying.",
  "reset_at": 1234567890
}

Agent Connect

2026-10-11. An AI assistant that has no API key can get one with a single email approval from the account owner, so the user never has to copy a key by hand. Three calls:

  1. POST /api/v1/agent/connect (no auth) with the owner's email and the assistant's name. We email the owner an approval link; they can sign in or sign up from it. Show the user the returned user_code: the approval page shows the same code so they can confirm the request is theirs.
  2. Poll GET /api/v1/agent/connect/{request_id}?secret=<poll_secret> every 10-30 seconds until status is approved. The response then carries api_key, exactly once; save it.
  3. Call the API (or MCP server) with Authorization: Bearer <api_key>.

The MCP server exposes the same flow as the connect_account and check_connection tools (no key needed).

POST /api/v1/agent/connect

Authentication: None
Rate Limit: 5 requests per IP per hour; 3 requests per email address per hour

Request Body:

{
  "email": "owner@example.com",
  "agent_name": "Claude",
  "mode": "live"
}
  • email (required): the account owner's email. The approval link is sent here.
  • agent_name (required): 1-80 characters; letters, digits, spaces and . , ' & ( ) - _ only (no links or email addresses), because it is shown in the email and on the approval page.
  • mode (optional): live (default) or test for a pl_test_ key.

Response (202): always 202 for a valid body, whether or not an account exists for the email.

{
  "request_id": "3f2b8c1e-7d4a-4b6e-9f0a-1c2d3e4f5a6b",
  "poll_url": "https://www.premierletters.com/api/v1/agent/connect/3f2b8c1e-7d4a-4b6e-9f0a-1c2d3e4f5a6b",
  "poll_secret": "Xq3...base64url...",
  "user_code": "KMP-7QX",
  "expires_at": "2026-10-12T15:04:05.000Z",
  "instructions": "Tell the user to open the email we just sent and approve the connection, and show them user_code ..."
}

Errors: invalid_json (400), validation_error (400, bad email, agent_name or mode), rate_limited (429, per IP or per email), connect_failed (502).

curl -X POST https://www.premierletters.com/api/v1/agent/connect \
  -H "Content-Type: application/json" \
  -d '{"email": "owner@example.com", "agent_name": "Claude"}'

GET /api/v1/agent/connect/{request_id}?secret=<poll_secret>

Authentication: None (the poll_secret proves the caller started the request)
Rate Limit: 30 requests/minute per IP

Response (200): one of

{ "status": "pending" }
{ "status": "denied" }
{ "status": "expired" }
{ "status": "claimed" }
{ "status": "approved", "api_key": "pl_live_...", "mode": "live" }
  • pending: not approved yet; poll again in 10-30 seconds.
  • approved: returned once, with the key. Later polls answer claimed.
  • denied: the owner declined. Do not retry unless asked.
  • expired: the request was not approved within 24 hours, or an approved key was not claimed within 1 hour (the key is then deactivated). Start a new request.

Errors: not_found (404; an unknown id and a wrong secret look the same), rate_limited (429).


Test Mode

2026-10-11. Keys that start with pl_test_ (api_keys.mode = 'test') are for building and testing an integration. They authenticate normally and run the same validation, pricing, return-address gate and payment-method checks as a live key, but never create, charge or send anything:

EndpointTest-key behaviour
POST /api/v1/orders201 with "test": true, "status": "test", "order_id": "test_<uuid>", the real total_cents and return_address.source. No order row, charge, balance debit or webhook. Idempotency keys are not stored.
POST /api/v1/validate, /api/v1/preview, /api/v1/styles, /api/v1/configSame as live
GET /api/v1/ordersEmpty list with "test": true
GET /api/v1/orders/{id}, /tracking404 order_not_found (test keys never create orders)
GET /api/v1/balanceSame fields, balance_cents: 0, "test": true
POST /api/v1/balance/topup, POST /api/v1/payment/charge-new-card, Growth record writes400 test_mode
/api/v1/webhooks*Allowed (endpoints are integration config)

The MCP server applies the same rules to a test key (create_order returns test: true).

Webhooks and test keys. A test key can list, create, update and delete the account's webhook endpoints, and those endpoints are account-wide: an endpoint registered with a test key receives the account's live order events (test orders send none). Register test endpoints carefully, and point them at a receiver that can handle real events.


Return Address

2026-10-11. Every order with an envelope needs a return address. POST /api/v1/orders (and /validate) resolve it in this order. An explicit no_return_address_ack: true always wins; otherwise a supplied return_address, else the account default:

  1. no_return_address_ack: true (envelope orders): no return address, even when the request also carries a return_address or the account has a default (2026-10-21). Undeliverable mail is then destroyed;
  2. otherwise return_address in the request body, if given;
  3. otherwise the account's default return address (Settings → return address);
  4. otherwise the request is refused with 400 no_return_address_unacknowledged.
"return_address": {
  "name": "Acme Dental",
  "company": "Acme Dental LLC",
  "address_line1": "100 Congress Ave",
  "address_line2": "Suite 200",
  "city": "Austin",
  "state": "TX",
  "zip": "78701"
}

name, address_line1, city, state (two-letter code) and zip (5-digit or ZIP+4) are required; company and address_line2 are optional. Invalid fields are a 422 validation_failed with field: "return_address.<name>".

Partial return address (2026-10-20). Set return_address_partial_ok: true to send an incomplete address exactly as written: at least one of name, company, address_line1, city, state, zip is needed, and a state or zip that is given is still format-checked. Only the filled lines print on the envelope (a name alone is fine; the "city, state ZIP" line is left out when all three are blank). Without the flag an incomplete address is refused, and the errors include field: "return_address_partial_ok" saying so. An explicit no_return_address_ack: true always wins over a supplied or default address. Orders without an envelope (include_envelope: false) need no return address.

Every order response reports which one was used:

"return_address": { "source": "request" }

source is request, profile (the account default) or none.

The needs_return_address hold (2026-10-19): orders that reach us through other channels (web guest checkout, inbound email) with no return address and no recorded opt-out are held with status: "needs_return_address" until a human adds an address or records the opt-out. API-created orders never enter this state — the 400 above catches it at creation — but GET /api/v1/orders lists every order on the account, so the status can appear in list and detail responses and is worth handling as "waiting on the sender, not on payment or production".


Printed Output

2026-10-11. Letters are pen-written by default. Two independent options print instead:

  • options.output_mode: handwritten (default), printed_bw or printed_color. A printed body replaces the pen-written page with a printed sheet; the character meter does not apply. The envelope stays hand-addressed.
  • options.envelope_output_mode: null (default, hand-addressed), printed_bw or printed_color. Adds the printed envelope charge.

Prices come from the live pricing config: quote with POST /api/v1/validate, the MCP get_pricing tool (which accepts the same two fields) or GET /api/v1/config. options.mailing_option (we_mail, you_mail_stamped, you_mail_no_stamp) is honoured as sent; omit it to use the account's default mailing option.


Cards & Postcards

2026-10-29 (the fields shipped earlier; this documents them). options.product_type accepts letter (default), card (a folded card) and postcard. Postcards never carry an envelope (include_envelope is forced off). Folded cards take two top-level request fields (siblings of message, not inside options):

  • card_orientation: portrait (default — the fold runs horizontally, card taller than wide) or landscape (vertical fold, wider than tall). Ignored for letters and postcards.
  • card_write_mode: where the pen writes on the folded card. standard (default) writes the primary half — the bottom for a horizontal fold, the right for a vertical fold; reverse writes the other half; full writes across the entire surface and adds a $0.50/recipient surcharge (quoted by /validate, which accepts both fields).

One more top-level field exists for pre-printed stock: page_background ({ storage_path, source_type: "pdf"|"image", opacity }) composites your letterhead artwork under the handwriting in previews only — it is never printed or pen-written (preview_only is forced true server-side), and the storage_path must already exist in storage. Most API callers should omit it.


Long Messages & Pages

A message that does not fit one sheet is written across several sheets automatically — nothing to opt into, and no error. Two things to know:

  • Price scales with message length through the character meter (GET /api/v1/config publishes the live meter; POST /api/v1/validate quotes any message exactly). Messages above the meter's maximum are refused with a validation_failed that states the limit.
  • Fit: POST /api/v1/preview returns page_count alongside the rendered SVG, so an agent can tell a caller "this letter runs to 2 pages" before ordering. /validate does not render and so does not report pages.

Idempotency

2026-10-11. Network retries must not create a second order or a second charge. Send an idempotency key with every POST /api/v1/orders, either in the body or as a header:

-H "Idempotency-Key: 6f1c2a9e-order-for-jane-2026-10-11"
{ "idempotency_key": "6f1c2a9e-order-for-jane-2026-10-11", "message": "...", "recipients": [] }
  • 1-255 characters, unique per account. When both are present the body's idempotency_key wins.
  • A repeat with the same key and the same request body returns the original order with HTTP 200 and "idempotent_replay": true. Nothing new is created or charged. If the order is still awaiting payment, the replay carries the original order's checkout_url (the same Checkout session, not a new one); once that session has expired or the order is no longer pending_payment, the replay has no checkout_url.
  • The same key with any difference in the request body other than idempotency_key is 422 idempotency_key_reused. Omitted defaults count as their default (payment.method checkout_session, no_return_address_ack and skip_invalid false, options {}), so a retry may omit them. Use a new key for a different order.
  • A repeat while the original request is still being processed (up to 120 seconds) is 409 idempotency_in_progress. Retry in a few seconds.
  • The MCP create_order tool takes the same idempotency_key argument.

Keys are stored on the order (orders.idempotency_key, unique per user), so they never expire.


Balance

2026-10-11. Accounts can prepay a balance and pay orders from it with payment.method: "balance". Paying from the balance waives the small-order handling fee; when the balance is short the order is refused with 402 insufficient_balance and nothing is created or charged.

GET /api/v1/balance

Authentication: Required
Rate Limit: 60 requests/minute (default endpoint limit)

Response (200):

{
  "balance_cents": 12500,
  "min_cents": 1000,
  "max_cents": 6000000,
  "tiers": [{ "min_cents": 49900, "bonus_pct": 5 }],
  "presets_cents": [1000, 49900],
  "next_tier": { "min_cents": 49900, "bonus_pct": 5 },
  "handling_fee_waived_when_paying_from_balance": true
}

Tier thresholds and bonuses come from the live pricing config; the values above are illustrative. A test key gets the same fields with balance_cents: 0 and "test": true.

POST /api/v1/balance/topup

Creates a Stripe Checkout link that adds amount_cents (plus any tier bonus) to the balance once a human pays it. The credit is granted by the Stripe webhook when the payment completes, never by this call.

Authentication: Required (live key)
Rate Limit: 10 per account per hour

Request Body: { "amount_cents": 5000 } (integer, between min_cents and max_cents)

Response (200):

{
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
  "amount_cents": 5000,
  "bonus_cents": 0,
  "min_cents": 1000,
  "max_cents": 6000000
}

Errors: invalid_amount, below_minimum, above_maximum (400, with min_cents / max_cents), test_mode (400, test keys), blocked (403), rate_limited (429, with Retry-After), checkout_failed (500).

The MCP server exposes these as get_balance and create_topup_link.


Endpoint Reference

Authentication Endpoints

GET /api/v1/auth/verify

Verify API key and retrieve user account information.

Authentication: Required
Rate Limit: 60 requests/minute (default endpoint limit)

Response (200):

{
  "valid": true,
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "user@example.com",
    "first_name": "John",
    "last_name": "Doe"
  },
  "payment": {
    "has_payment_method": true,
    "card_last4": "4242",
    "card_brand": "visa"
  }
}

Errors:

  • missing_api_key (401): No API key provided
  • invalid_api_key (401): API key is invalid or inactive
  • fetch_failed (500): Failed to fetch user profile

Example:

curl -X GET https://premierletters.com/api/v1/auth/verify \
  -H "Authorization: Bearer pl_live_xyz123"

Configuration Endpoints

GET /api/v1/config

Retrieve public configuration including Stripe publishable key and pricing.

Authentication: None
Rate Limit: Unlimited

Response (200) (2026-09-20: now includes live tiers, the character meter, service levels, and agent-surface URLs — price_per_letter_cents is derived from the "We Mail It" tier at request time, not a literal):

{
  "stripe_publishable_key": "pk_live_...",
  "price_per_letter_cents": 549,
  "tiers": [
    { "id": "we_mail", "label": "We Mail It", "description": "...", "base_cents": 549 },
    { "id": "you_mail_stamped", "label": "Ship to Me (with stamps)", "description": "...", "base_cents": 549 },
    { "id": "you_mail_no_stamp", "label": "Ship to Me (no postage)", "description": "...", "base_cents": 484 }
  ],
  "paper_qualities": [
    { "id": "standard", "label": "Standard", "surcharge_cents": 0 },
    { "id": "premium", "label": "Premium", "surcharge_cents": 45 }
  ],
  "char_meter": { "included_chars": 300, "block_chars": 100, "block_cents": 75, "max_chars": 7500 },
  "small_order_handling": {
    "per_piece_cents": 99,
    "below_pieces": 5,
    "summary": "Orders under 5 pieces carry a $0.99 per-piece handling fee, waived when paid from a prepaid balance."
  },
  "service_levels": {
    "write_and_mail_business_days": 2,
    "usps_transit_business_days": { "min": 3, "max": 5 },
    "summary": "We write and mail within 2 business days; USPS First-Class delivery then typically takes 3–5 business days."
  },
  "mcp_endpoint": "https://premierletters.com/api/mcp",
  "openapi_url": "https://premierletters.com/openapi.json"
}

Example:

curl -X GET https://premierletters.com/api/v1/config

GET/POST /api/v1/preview

Render a letter to SVG and get its price — no order is created or saved. GET accepts the same fields as query params.

Authentication: Required
Rate Limit: 60 requests/minute (default endpoint limit)

Request Body:

{
  "message": "Dear Jane,\n\nThank you for your business.",
  "handwriting_style": "550e8400-e29b-41d4-a716-446655440000",
  "product_type": "letter",
  "paper_type": "standard"
}

Response (200):

{
  "svg_data_url": "data:image/svg+xml;base64,...",
  "svg": "<svg ...>...</svg>",
  "svg_truncated": false,
  "char_count": 42,
  "page_count": 1,
  "font_id": "3efccf0e-09a2-46b1-9a67-af3f0c843007",
  "product_type": "letter",
  "price": { "per_letter_cents": 549, "total_cents": 549, "formatted": "$5.49" }
}

Errors:

  • missing_message (400): message field is required
  • render_failed (500): Invalid handwriting_style or product_type

Example:

curl -X POST https://premierletters.com/api/v1/preview \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{"message": "Dear Jane,\n\nThank you!"}'

Style/Font Endpoints

GET /api/v1/styles

List available handwriting styles for orders.

Authentication: Required
Rate Limit: 60 requests/minute (default endpoint limit)

Response (200):

{
  "styles": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Elegant Script",
      "description": "A flowing, sophisticated handwriting style",
      "preview_url": "https://premierletters.com/previews/style1.png",
      "is_premium": false
    }
  ],
  "default": "default",
  "suggestion": "Use the \"id\" value in options.handwriting_style when creating an order."
}

Errors:

  • fetch_failed (500): Failed to fetch styles

Example:

curl -X GET https://premierletters.com/api/v1/styles \
  -H "Authorization: Bearer pl_live_xyz123"

Order Validation Endpoints

POST /api/v1/validate

Validate an order without creating it. Returns pricing estimate.

Authentication: Required
Rate Limit: 100 requests/minute
Request Body: Same shape as POST /api/v1/orders (without payment)

Request Body:

{
  "message": "Dear John,\n\nThank you for your business.",
  "recipients": [
    {
      "first_name": "John",
      "last_name": "Doe",
      "address_line1": "123 Main St",
      "address_line2": "Apt 4B",
      "city": "Austin",
      "state": "TX",
      "zip": "78701"
    }
  ],
  "options": {
    "handwriting_style": "550e8400-e29b-41d4-a716-446655440000",
    "paper_type": "standard",
    "product_type": "letter",
    "authenticity_preset": "natural",
    "mailing_option": "we_mail"
  }
}

options.mailing_option (2026-09-20) is optional — we_mail, you_mail_stamped, or you_mail_no_stamp. Falls back to the caller's profile default, then the system default (we_mail), exactly like POST /api/v1/orders.

Response (200) (now includes a full breakdown, matching PricingBreakdown in the OpenAPI spec):

{
  "valid": true,
  "letter_count": 1,
  "total_cents": 549,
  "total_formatted": "$5.49",
  "options": {
    "handwriting_style": "550e8400-e29b-41d4-a716-446655440000",
    "paper_type": "standard",
    "product_type": "letter",
    "authenticity_preset": "natural",
    "mailing_option": "we_mail"
  },
  "breakdown": {
    "basePrice": 549,
    "paperSurchargeCents": 0,
    "printedPageSurchargeCents": 0,
    "printedEnvelopeSurchargeCents": 0,
    "signatureSurchargeCents": 0,
    "meterSurchargeCents": 0,
    "totalCents": 549,
    "perLetterCents": 549,
    "pieceCount": 1
  },
  "message": "Order is valid. 1 letter at $5.49 each = $5.49 total.",
  "suggestion": "Submit to POST /api/v1/orders with a payment method to create the order."
}

Response (422) - Validation Failed:

{
  "error": "validation_failed",
  "message": "1 recipient has issues",
  "details": [
    {
      "recipient_index": 0,
      "field": "zip",
      "error": "Invalid ZIP code format \"ABC\". Expected 5 digits or ZIP+4 (e.g., 78701 or 78701-1234)"
    }
  ],
  "valid_count": 0,
  "invalid_count": 1,
  "suggestion": "Fix the 1 invalid recipient and resubmit, or set \"skip_invalid\": true to process only the 0 valid recipients."
}

Errors:

  • invalid_json (400): Request body is not valid JSON
  • validation_failed (422): One or more validation errors
  • fetch_failed (500): Failed to fetch profile defaults

Field Validation:

  • message: Required, 1-7500 characters
  • recipients: Array of 1-100 objects
    • first_name: Required, max 100 chars
    • last_name: Required, max 100 chars
    • address_line1: Required, max 255 chars
    • address_line2: Optional, max 255 chars
    • city: Required, max 100 chars
    • state: Required, 2-letter US state code
    • zip: Required, 5-digit or ZIP+4 format
    • custom_fields: Optional object for merge fields

Options:

  • handwriting_style: Optional, font UUID or legacy slug. Defaults to user profile default, then system default.
  • paper_type: Optional, 'standard' or 'premium'. Defaults to 'standard'.
  • product_type: Optional, 'letter', 'card', or 'postcard'. Defaults to 'letter'.
  • authenticity_preset: Optional, preset key (e.g., 'natural', 'classic'). Defaults to user profile default or system default.

Example:

curl -X POST https://premierletters.com/api/v1/validate \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "message": "Thank you!",
    "recipients": [
      {
        "first_name": "Jane",
        "last_name": "Smith",
        "address_line1": "456 Oak Ave",
        "city": "Portland",
        "state": "OR",
        "zip": "97201"
      }
    ]
  }'

Order Management Endpoints

GET /api/v1/orders

List user's orders with pagination and filtering.

Authentication: Required
Rate Limit: 10 requests/minute (shared with POST /api/v1/orders)
Query Parameters:

  • limit: Max 100, default 20
  • offset: Default 0
  • status: Optional filter (pending_payment, paid, in_production, shipped, delivered, cancelled, refunded)

Response (200):

{
  "orders": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "status": "paid",
      "letter_count": 2,
      "total_cents": 998,
      "total_formatted": "$9.98",
      "source": "api",
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:31:00Z"
    }
  ],
  "pagination": {
    "total": 45,
    "limit": 20,
    "offset": 0,
    "has_more": true
  }
}

Errors:

  • fetch_failed (500): Failed to fetch orders

Example:

curl -X GET "https://premierletters.com/api/v1/orders?limit=10&offset=0&status=paid" \
  -H "Authorization: Bearer pl_live_xyz123"

POST /api/v1/orders

Create a new order. Validates all fields, creates recipients, handles payment, and sends webhooks.

Authentication: Required
Rate Limit: 10 requests/minute
Headers: optional Idempotency-Key (see Idempotency)
Request Body: Validated order with payment method

Request Body:

{
  "message": "Dear Customer,\n\nThank you for your purchase.",
  "recipients": [
    {
      "first_name": "Alice",
      "last_name": "Johnson",
      "address_line1": "789 Elm St",
      "city": "Seattle",
      "state": "WA",
      "zip": "98101"
    }
  ],
  "options": {
    "handwriting_style": "550e8400-e29b-41d4-a716-446655440000",
    "paper_type": "premium",
    "product_type": "letter",
    "authenticity_preset": "handcrafted",
    "mailing_option": "we_mail",
    "output_mode": "handwritten",
    "envelope_output_mode": null
  },
  "return_address": {
    "name": "Acme Dental",
    "address_line1": "100 Congress Ave",
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  },
  "payment": {
    "method": "checkout_session",
    "return_url": "https://yourapp.com/order-complete"
  },
  "idempotency_key": "6f1c2a9e-order-for-alice-2026-10-11",
  "skip_invalid": false
}

Response (201) - Paid Order:

{
  "order_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "paid",
  "letter_count": 1,
  "total_cents": 549,
  "tracking_url": "https://premierletters.com/orders/550e8400-e29b-41d4-a716-446655440000",
  "return_address": { "source": "request" }
}

Response (200) - Idempotent replay: the same body as the original response plus "idempotent_replay": true.

Response (201) - Test key: "test": true, "status": "test", "order_id": "test_<uuid>" and the real total_cents; nothing is created or charged (see Test Mode).

A card-on-file order is answered 201 once the card is charged, even if marking the order paid hits a transient error on our side. The body then reports "status": "pending_payment" (and no order.paid webhook is sent yet); the order moves to paid when we resolve it, with no action needed from you. Do not resubmit: the card has already been charged. Each paid order sends exactly one order.paid webhook.

Response (202) - Pending Payment:

{
  "order_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending_payment",
  "letter_count": 1,
  "total_cents": 549,
  "checkout_url": "https://checkout.stripe.com/pay/cs_live_...",
  "tracking_url": "https://premierletters.com/orders/550e8400-e29b-41d4-a716-446655440000",
  "return_address": { "source": "profile" }
}

Response (402) - Card on file could not be charged:

{
  "error": "payment_failed",
  "message": "Payment failed: requires_action",
  "suggestion": "Try a different payment method, or complete payment for this order at checkout_url.",
  "order_id": "550e8400-e29b-41d4-a716-446655440000",
  "checkout_url": "https://checkout.stripe.com/pay/cs_live_..."
}

Payment Methods:

  • card_on_file: Charge the saved card. If there is no saved card or the charge fails, the order is still created, as pending_payment, and the response is a 402 carrying order_id and a checkout_url that pays exactly that order. Do not resubmit the request after a 402: that creates a second order. Send the customer to checkout_url, or poll GET /api/v1/orders/{order_id}.
  • checkout_session: The order is created as pending_payment and a Stripe Checkout session is opened for it. Returns 202 with checkout_url; the order moves to paid when that session is paid.
  • balance: Pay from the account's prepaid balance (the small-order handling fee is waived). Returns 201 when the balance covers the total. When it does not, nothing is charged and no order is kept; the response is a 402 (if the payment fails for another reason the response is a 500 balance_payment_failed: the order is not placed, and if your balance changed it is corrected automatically):
    {
      "error": "insufficient_balance",
      "message": "Your prepaid balance does not cover this order.",
      "suggestion": "Top up at https://www.premierletters.com/settings/billing",
      "balance_cents": 300,
      "shortfall_cents": 254
    }
    
  • invoice: Not yet supported (returns 400 payment_method_not_supported).

payment.method is one of card_on_file, checkout_session, invoice, balance; any other value returns 400 invalid_payment_method.

Orders under 5 pieces carry a per-piece small-order handling fee (see small_order_handling in GET /api/v1/config). It is included in total_cents and in the Stripe amount for card_on_file and checkout_session, and waived for balance.

Errors:

  • invalid_json (400): Request body is not valid JSON
  • validation_failed (422): Validation failed (see details)
  • no_return_address_unacknowledged (400): Envelope order with no return_address, no account default and no no_return_address_ack: true
  • idempotency_key_reused (422): This idempotency_key was already used with a different request body
  • idempotency_in_progress (409): The original request with this idempotency_key is still being processed; retry in a few seconds
  • invalid_payment_method (400): Unknown payment method
  • no_payment_method (402): Method is card_on_file and there is no saved card. The order exists as pending_payment; the body includes order_id and checkout_url. Do not retry.
  • payment_failed (402): The card-on-file charge failed. The order exists as pending_payment; the body includes order_id and checkout_url. Do not retry.
  • insufficient_balance (402, balance method): Balance too low; body includes balance_cents, shortfall_cents and a suggestion linking to the top-up page. No order is created and nothing is charged.
  • balance_payment_failed (500, balance method): The balance payment failed for another reason. The order is not placed; if your balance changed it is corrected automatically.
  • order_creation_failed (500): Failed to create order in database. Any card charge taken for it is released.
  • recipient_creation_failed (500): Failed to create recipients. Any card charge is released and the order removed.
  • checkout_unavailable (502): Stripe Checkout could not be started. No order was created and nothing was charged; safe to retry.

Example:

curl -X POST https://premierletters.com/api/v1/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -H "Idempotency-Key: thank-you-bob-2026-10-11" \
  -d '{
    "message": "Thank you for your business!",
    "recipients": [
      {
        "first_name": "Bob",
        "last_name": "Wilson",
        "address_line1": "321 Pine Rd",
        "city": "Denver",
        "state": "CO",
        "zip": "80202"
      }
    ],
    "payment": {
      "method": "checkout_session"
    }
  }'

GET /api/v1/orders/{id}

Get order detail with all recipients and tracking information.

Authentication: Required
Rate Limit: 10 requests/minute
Path Parameters:

  • id: Order UUID

Response (200):

{
  "order": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "paid",
    "letter_count": 2,
    "total_cents": 998,
    "total_formatted": "$9.98",
    "message": "Thank you for your purchase!",
    "options": {
      "handwriting_style": "550e8400-e29b-41d4-a716-446655440000",
      "paper_type": "standard",
      "product_type": "letter"
    },
    "estimated_ship_date": "2024-01-20T00:00:00Z",
    "shipped_date": null,
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T10:31:00Z",
    "tracking_url": "https://premierletters.com/orders/550e8400-e29b-41d4-a716-446655440000"
  },
  "recipients": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440001",
      "name": "John Doe",
      "address": {
        "line1": "123 Main St",
        "line2": "Apt 4B",
        "city": "Austin",
        "state": "TX",
        "zip": "78701"
      },
      "custom_fields": {},
      "status": "writing",
      "tracking_number": "9400111899223456789012",
      "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223456789012",
      "qc_photo_url": null,
      "mailed_at": null
    }
  ],
  "summary": {
    "total": 2,
    "pending": 0,
    "writing": 1,
    "qc": 1,
    "mailed": 0
  }
}

Errors:

  • invalid_order_id (400): Order ID is not a valid UUID
  • order_not_found (404): Order does not exist or does not belong to user

Example:

curl -X GET https://premierletters.com/api/v1/orders/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer pl_live_xyz123"

GET /api/v1/orders/{id}/tracking

Get tracking information for all recipients in an order (lightweight endpoint).

Authentication: Required
Rate Limit: 10 requests/minute
Path Parameters:

  • id: Order UUID

Response (200):

{
  "summary": {
    "order_id": "550e8400-e29b-41d4-a716-446655440000",
    "order_status": "paid",
    "shipped_date": null,
    "total_recipients": 2,
    "with_tracking": 1,
    "mailed": 0
  },
  "tracking": [
    {
      "recipient_id": "550e8400-e29b-41d4-a716-446655440001",
      "name": "John Doe",
      "location": "Austin, TX",
      "status": "writing",
      "tracking_number": "9400111899223456789012",
      "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223456789012",
      "mailed_at": null
    }
  ]
}

Errors:

  • invalid_order_id (400): Order ID is not a valid UUID
  • order_not_found (404): Order does not exist
  • fetch_failed (500): Failed to fetch tracking

Example:

curl -X GET https://premierletters.com/api/v1/orders/550e8400-e29b-41d4-a716-446655440000/tracking \
  -H "Authorization: Bearer pl_live_xyz123"

Payment Endpoints

POST /api/v1/payment/charge-new-card

Charge a new card using Stripe PaymentMethod ID from client-side Elements.

Authentication: Required
Rate Limit: 60 requests/minute (default endpoint limit)
Request Body: Order data + Stripe PaymentMethod ID

Request Body:

{
  "message": "Thank you!",
  "recipients": [
    {
      "first_name": "Charlie",
      "last_name": "Brown",
      "address_line1": "999 Spruce Ln",
      "city": "Boston",
      "state": "MA",
      "zip": "02101"
    }
  ],
  "options": {
    "paper_type": "premium"
  },
  "payment_method_id": "pm_1234567890abcdefghijklmn",
  "save_card": true
}

Response (201):

{
  "order_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "paid",
  "letter_count": 1,
  "total_cents": 549,
  "tracking_url": "https://premierletters.com/orders/550e8400-e29b-41d4-a716-446655440000",
  "card_saved": true,
  "card_info": {
    "brand": "visa",
    "last4": "4242"
  }
}

Response (200) - 3D Secure Required:

{
  "requires_action": true,
  "payment_intent_client_secret": "pi_1234567890abcdefghijklmn_secret_...",
  "message": "Payment requires additional authentication"
}

Errors:

  • invalid_json (400): Request body is not valid JSON
  • missing_payment_method (400): payment_method_id is missing
  • validation_failed (422): Order validation failed
  • card_declined (402): Card was declined
  • payment_failed (402/500): Payment processing failed
  • order_creation_failed (500): Failed to create order
  • recipient_creation_failed (500): Failed to create recipients

Example:

curl -X POST https://premierletters.com/api/v1/payment/charge-new-card \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "message": "Hello!",
    "recipients": [
      {
        "first_name": "David",
        "last_name": "Lee",
        "address_line1": "555 Oak Way",
        "city": "Portland",
        "state": "OR",
        "zip": "97201"
      }
    ],
    "payment_method_id": "pm_abcdef123456",
    "save_card": false
  }'

Webhook Management Endpoints

GET /api/v1/webhooks

List user's webhook endpoints.

Authentication: Required
Rate Limit: 30 requests/minute

Response (200):

{
  "endpoints": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "url": "https://yourapp.com/webhooks/premier",
      "events": ["order.paid", "order.shipped"],
      "is_active": true,
      "created_at": "2024-01-10T15:00:00Z",
      "updated_at": "2024-01-10T15:00:00Z"
    }
  ],
  "valid_events": ["order.created", "order.paid", "order.in_production", "order.shipped", "order.delivered", "order.cancelled"]
}

Errors:

  • fetch_failed (500): Failed to fetch endpoints

Example:

curl -X GET https://premierletters.com/api/v1/webhooks \
  -H "Authorization: Bearer pl_live_xyz123"

POST /api/v1/webhooks

Create a new webhook endpoint. Returns signing secret once.

Authentication: Required
Rate Limit: 30 requests/minute
Request Body:

{
  "url": "https://yourapp.com/webhooks/premier",
  "events": ["order.paid", "order.shipped"]
}

Response (201):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://yourapp.com/webhooks/premier",
  "events": ["order.paid", "order.shipped"],
  "is_active": true,
  "created_at": "2024-01-10T15:00:00Z",
  "secret": "whsec_abcdef1234567890abcdef1234567890",
  "warning": "Store this secret securely. It will not be shown again."
}

Valid Events:

  • order.created: Order was created
  • order.paid: Payment confirmed
  • order.in_production: Order entered production
  • order.shipped: Order shipped
  • order.delivered: Order delivered
  • order.cancelled: Order cancelled

Errors:

  • invalid_json (400): Request body is not valid JSON
  • missing_url (400): url field is missing
  • invalid_url (400): url is not a valid HTTP/HTTPS URL
  • invalid_events (400): events contains invalid event types
  • creation_failed (500): Failed to create webhook

Example:

curl -X POST https://premierletters.com/api/v1/webhooks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "url": "https://yourapp.com/webhooks/letters",
    "events": ["order.paid", "order.shipped"]
  }'

GET /api/v1/webhooks/{id}

Get a specific webhook endpoint.

Authentication: Required
Path Parameters:

  • id: Webhook endpoint UUID

Response (200):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://yourapp.com/webhooks/premier",
  "events": ["order.paid", "order.shipped"],
  "is_active": true,
  "created_at": "2024-01-10T15:00:00Z",
  "updated_at": "2024-01-10T15:00:00Z"
}

Errors:

  • not_found (404): Webhook endpoint not found

Example:

curl -X GET https://premierletters.com/api/v1/webhooks/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer pl_live_xyz123"

PUT /api/v1/webhooks/{id}

Update a webhook endpoint (url, events, is_active).

Authentication: Required
Path Parameters:

  • id: Webhook endpoint UUID

Request Body (all fields optional):

{
  "url": "https://yourapp.com/webhooks/premier-v2",
  "events": ["order.shipped", "order.delivered"],
  "is_active": false
}

Response (200):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://yourapp.com/webhooks/premier-v2",
  "events": ["order.shipped", "order.delivered"],
  "is_active": false,
  "created_at": "2024-01-10T15:00:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}

Errors:

  • invalid_json (400): Request body is not valid JSON
  • invalid_url (400): url is not a valid HTTP/HTTPS URL
  • invalid_events (400): events contains invalid event types
  • no_updates (400): No valid fields provided
  • not_found (404): Webhook endpoint not found
  • update_failed (500): Failed to update endpoint

Example:

curl -X PUT https://premierletters.com/api/v1/webhooks/550e8400-e29b-41d4-a716-446655440000 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "is_active": false
  }'

DELETE /api/v1/webhooks/{id}

Delete a webhook endpoint.

Authentication: Required
Path Parameters:

  • id: Webhook endpoint UUID

Response (204): No content

Errors:

  • not_found (404): Webhook endpoint not found
  • delete_failed (500): Failed to delete endpoint

Example:

curl -X DELETE https://premierletters.com/api/v1/webhooks/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer pl_live_xyz123"

GET /api/v1/webhooks/{id}/deliveries

List delivery attempts for a webhook endpoint.

Authentication: Required
Query Parameters:

  • limit: Max 100, default 50
  • offset: Default 0

Response (200):

{
  "deliveries": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440100",
      "event_type": "order.paid",
      "response_status": 200,
      "status": "success",
      "attempts": 1,
      "created_at": "2024-01-15T10:30:00Z"
    }
  ],
  "pagination": {
    "total": 42,
    "limit": 50,
    "offset": 0,
    "has_more": false
  }
}

Errors:

  • not_found (404): Webhook endpoint not found
  • fetch_failed (500): Failed to fetch deliveries

Example:

curl -X GET "https://premierletters.com/api/v1/webhooks/550e8400-e29b-41d4-a716-446655440000/deliveries?limit=25" \
  -H "Authorization: Bearer pl_live_xyz123"

Partner API (Tenant-Scoped)

The Partner API enables partners to integrate order creation, balance checking, and webhook management at the tenant level. All endpoints require partner API authentication via the Authorization: Bearer pl_live_<key> header, where the key is a tenant API key (from tenant_api_keys table).

Partner Authentication

Partner API keys are stored in the tenant_api_keys table and include:

  • key_hash: bcrypt hash
  • key_prefix: display prefix
  • permissions: array of permission strings
  • is_active: whether key is valid
  • expires_at: optional expiration

Partner Endpoints

GET /api/v1/partner/keys

List partner API keys for the tenant (prefix only).

Authentication: Required (partner key)
Permission: keys.manage (optional — anyone can list their own keys)

Response (200):

{
  "keys": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Production Integration",
      "key_prefix": "pl_live_abc...",
      "permissions": ["orders.create", "orders.read"],
      "last_used_at": "2024-01-15T10:30:00Z",
      "expires_at": null,
      "is_active": true,
      "created_at": "2024-01-10T15:00:00Z"
    }
  ]
}

Example:

curl -X GET https://premierletters.com/api/v1/partner/keys \
  -H "Authorization: Bearer pl_live_xyz123"

POST /api/v1/partner/keys

Create a new partner API key.

Authentication: Required (partner key)
Permission: keys.manage

Request Body:

{
  "name": "Staging Integration",
  "permissions": ["orders.create", "orders.read", "balance.read"],
  "expires_at": "2025-01-15T00:00:00Z"
}

Response (201):

{
  "key": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "name": "Staging Integration",
    "full_key": "pl_live_xyz123abcdef",
    "key_prefix": "pl_live_xyz...",
    "permissions": ["orders.create", "orders.read", "balance.read"],
    "expires_at": "2025-01-15T00:00:00Z",
    "is_active": true,
    "created_at": "2024-01-15T10:30:00Z"
  },
  "message": "API key created. Save the full key — it will not be shown again."
}

Errors:

  • invalid_json (400): Request body is not valid JSON
  • validation_error (400): name is required or permission is invalid
  • creation_failed (500): Failed to create key

Example:

curl -X POST https://premierletters.com/api/v1/partner/keys \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "name": "Testing",
    "permissions": ["orders.read"]
  }'

DELETE /api/v1/partner/keys/{id}

Revoke a partner API key.

Authentication: Required (partner key)
Permission: keys.manage
Path Parameters:

  • id: API key UUID

Response (200):

{
  "message": "API key revoked.",
  "key": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "is_active": false
  }
}

Errors:

  • missing_id (400): Key ID is missing
  • not_found (404): API key not found
  • self_revoke (409): Cannot revoke the key you are currently using
  • revocation_failed (500): Failed to revoke key

Example:

curl -X DELETE https://premierletters.com/api/v1/partner/keys/550e8400-e29b-41d4-a716-446655440001 \
  -H "Authorization: Bearer pl_live_xyz123"

GET /api/v1/partner/orders

List tenant's orders with pagination and filtering.

Authentication: Required (partner key)
Permission: orders.read
Query Parameters:

  • page: Default 1
  • limit: Max 100, default 25
  • status: Optional filter
  • sort: Field name (created_at, updated_at, total_cents, letter_count, status), default created_at
  • order: 'asc' or 'desc', default desc

Response (200):

{
  "orders": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "status": "paid",
      "letter_count": 5,
      "total_cents": 2495,
      "message_body": "Thank you for your order!",
      "handwriting_style": "550e8400-e29b-41d4-a716-446655440200",
      "paper_type": "standard",
      "product_type": "letter",
      "envelope_type": "standard",
      "source": "partner_api",
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:30:00Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 127,
    "total_pages": 6
  }
}

Errors:

  • fetch_failed (500): Failed to retrieve orders

Example:

curl -X GET "https://premierletters.com/api/v1/partner/orders?page=2&limit=50" \
  -H "Authorization: Bearer pl_live_xyz123"

POST /api/v1/partner/orders

Create a new order as a partner.

Authentication: Required (partner key)
Permission: orders.create
Request Body: Partner order payload (different schema from user API)

Request Body:

{
  "message_body": "Congratulations on your promotion!",
  "recipients": [
    {
      "first_name": "Emma",
      "last_name": "Wilson",
      "address_line1": "789 Pine Ave",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94102"
    }
  ],
  "product_type": "letter",
  "paper_type": "premium",
  "mailing_option": "we_mail",
  "envelope_type": "standard",
  "handwriting_style": "550e8400-e29b-41d4-a716-446655440200",
  "authenticity_preset": "natural",
  "return_address": {
    "name": "Acme Corp",
    "address_line1": "123 Business Blvd",
    "city": "Los Angeles",
    "state": "CA",
    "zip": "90001"
  }
}

Response (201):

{
  "order": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "status": "paid",
    "letter_count": 1,
    "total_cents": 549,
    "product_type": "letter",
    "paper_type": "premium",
    "envelope_type": "standard",
    "recipient_count": 1,
    "cost_breakdown": {
      "letter_cost_cents": 499,
      "envelope_cost_cents": 50,
      "total_letters": 1,
      "total_envelopes": 1,
      "subtotal_cents": 549,
      "description": "1 letter + 1 envelope at $4.99 + $0.50"
    },
    "created_at": "2024-01-15T10:30:00Z"
  },
  "balance_cents": 450000,
  "transaction_id": "txn_550e8400"
}

Limits: message_body up to MAX_MESSAGE_CHARS characters and up to MAX_RECIPIENTS_PER_ORDER (100) recipients per order (both from lib/shared/order-config.ts); recipient fields are capped at their column widths. Violations return validation_error (400).

Order ownership: orders.user_id is NOT NULL, so partner orders are owned by a per-tenant service profile whose id is stored in tenants.settings.partner_order_user_id (the profile's tenant_id must match). A tenant without one gets tenant_not_provisioned (503) and is not charged. Orders are inserted as pending_payment and become paid once credits are deducted.

Response (402) - Insufficient Credits:

{
  "error": "insufficient_credits",
  "message": "Insufficient credit balance for this order.",
  "balance_cents": 100,
  "cost_cents": 549,
  "shortfall_cents": 449
}

Field Validation:

  • message_body: Required, non-empty string
  • recipients: Required, 1+ objects with first_name, last_name, address_line1, city, state, zip
  • product_type: letter, card, or postcard
  • paper_type: standard or premium
  • mailing_option: Optional (we_mail, print_send, drop_ship), defaults to system default
  • envelope_type: standard or none
  • handwriting_style: Optional font UUID, defaults to system default
  • authenticity_preset: Optional preset key
  • return_address: Optional custom return address

Errors:

  • invalid_json (400): Request body is not valid JSON
  • validation_error (400): One or more validation errors
  • insufficient_credits (402): Not enough credits (credit tenants only)
  • order_creation_failed (500): Failed to create order
  • recipient_creation_failed (500): Failed to create recipients
  • credit_deduction_failed (500): Failed to deduct credits

Example:

curl -X POST https://premierletters.com/api/v1/partner/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "message_body": "Thank you!",
    "recipients": [
      {
        "first_name": "Frank",
        "last_name": "Miller",
        "address_line1": "500 Market St",
        "city": "San Francisco",
        "state": "CA",
        "zip": "94102"
      }
    ]
  }'

GET /api/v1/partner/orders/{id}

Get a specific partner order with recipients.

Authentication: Required (partner key)
Permission: orders.read
Path Parameters:

  • id: Order UUID

Response (200):

{
  "order": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "status": "paid",
    "letter_count": 1,
    "total_cents": 549,
    "message_body": "Thank you!",
    "handwriting_style": "550e8400-e29b-41d4-a716-446655440200",
    "paper_type": "premium",
    "product_type": "letter",
    "envelope_type": "standard",
    "source": "partner_api",
    "return_address": null,
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T10:30:00Z",
    "cancelled_at": null,
    "recipients": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440300",
        "first_name": "Grace",
        "last_name": "Lee",
        "address_line1": "789 Elm St",
        "address_line2": null,
        "city": "Seattle",
        "state": "WA",
        "zip": "98101",
        "country": "US",
        "custom_fields": null,
        "status": "pending",
        "tracking_number": null,
        "mailed_at": null,
        "created_at": "2024-01-15T10:30:00Z"
      }
    ],
    "recipient_count": 1
  }
}

Errors:

  • missing_id (400): Order ID is missing
  • not_found (404): Order not found

Example:

curl -X GET https://premierletters.com/api/v1/partner/orders/550e8400-e29b-41d4-a716-446655440002 \
  -H "Authorization: Bearer pl_live_xyz123"

POST /api/v1/partner/orders/{id}/cancel

Cancel a partner order that has not entered production and refund credits.

Authentication: Required (partner key)
Permission: orders.create
Path Parameters:

  • id: Order UUID

Response (200):

{
  "order": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "status": "cancelled",
    "cancelled_at": "2024-01-15T11:00:00Z"
  },
  "balance_cents": 451000,
  "message": "Order cancelled successfully. Credits have been refunded."
}

Errors:

  • missing_id (400): Order ID is missing
  • not_found (404): Order not found
  • not_cancellable (409): Order status is not 'paid' (it has entered production, or is already cancelled)
  • cancel_failed (500): Failed to cancel order

Example:

curl -X POST https://premierletters.com/api/v1/partner/orders/550e8400-e29b-41d4-a716-446655440002/cancel \
  -H "Authorization: Bearer pl_live_xyz123"

GET /api/v1/partner/balance

Check tenant's credit balance and pricing.

Authentication: Required (partner key)
Permission: balance.read

Response (200):

{
  "balance_cents": 1000000,
  "low_balance_alert_cents": 50000,
  "pricing_model": "wholesale",
  "per_letter_cost_cents": 199,
  "per_envelope_cost_cents": 50,
  "estimated_letters_remaining": 4021
}

Errors:

  • fetch_failed (500): Failed to retrieve balance

Example:

curl -X GET https://premierletters.com/api/v1/partner/balance \
  -H "Authorization: Bearer pl_live_xyz123"

GET /api/v1/partner/fonts

List fonts available to the tenant.

Authentication: Required (partner key)
Permission: fonts.read

Response (200):

{
  "fonts": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440200",
      "name": "Classic Script",
      "description": "A timeless, elegant handwriting style",
      "category": "script",
      "is_default": true,
      "is_custom": false
    }
  ],
  "default_font_id": "550e8400-e29b-41d4-a716-446655440200",
  "total": 12
}

Errors:

  • fetch_failed (500): Failed to retrieve fonts

Example:

curl -X GET https://premierletters.com/api/v1/partner/fonts \
  -H "Authorization: Bearer pl_live_xyz123"

GET /api/v1/partner/webhooks

List partner webhooks for the tenant.

Authentication: Required (partner key)

Response (200):

{
  "webhooks": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440400",
      "url": "https://partner.com/webhooks/premier",
      "events": ["order.confirmed", "order.shipped"],
      "is_active": true,
      "last_triggered_at": "2024-01-15T10:30:00Z",
      "failure_count": 0,
      "created_at": "2024-01-10T15:00:00Z"
    }
  ]
}

Errors:

  • fetch_failed (500): Failed to retrieve webhooks

Example:

curl -X GET https://premierletters.com/api/v1/partner/webhooks \
  -H "Authorization: Bearer pl_live_xyz123"

POST /api/v1/partner/webhooks

Create a new partner webhook.

Authentication: Required (partner key)
Request Body:

{
  "url": "https://partner.com/webhooks/premier",
  "events": ["order.confirmed", "order.completed", "order.shipped"]
}

Response (201):

{
  "webhook": {
    "id": "550e8400-e29b-41d4-a716-446655440400",
    "url": "https://partner.com/webhooks/premier",
    "events": ["order.confirmed", "order.completed", "order.shipped"],
    "is_active": true,
    "created_at": "2024-01-15T10:30:00Z",
    "secret": "whsec_abcdef1234567890abcdef1234567890"
  },
  "message": "Webhook created. Save the secret — it will not be shown again."
}

Valid Events:

  • order.confirmed: Order created
  • order.completed: Order completed
  • order.cancelled: Order cancelled
  • order.shipped: Order shipped
  • letter.mailed: Individual letter mailed
  • *: All events

Errors:

  • invalid_json (400): Request body is not valid JSON
  • validation_error (400): Invalid URL or event type
  • creation_failed (500): Failed to create webhook

Example:

curl -X POST https://premierletters.com/api/v1/partner/webhooks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer pl_live_xyz123" \
  -d '{
    "url": "https://partner.com/webhooks/orders",
    "events": ["order.completed"]
  }'

DELETE /api/v1/partner/webhooks/{id}

Deactivate a partner webhook.

Authentication: Required (partner key)
Path Parameters:

  • id: Webhook UUID

Response (200):

{
  "message": "Webhook deactivated.",
  "webhook": {
    "id": "550e8400-e29b-41d4-a716-446655440400",
    "is_active": false
  }
}

Errors:

  • missing_id (400): Webhook ID is missing
  • not_found (404): Webhook not found
  • deactivation_failed (500): Failed to deactivate

Example:

curl -X DELETE https://premierletters.com/api/v1/partner/webhooks/550e8400-e29b-41d4-a716-446655440400 \
  -H "Authorization: Bearer pl_live_xyz123"

MCP Server & Agent Discovery

2026-09-20 (WS5): AI agents (Claude, ChatGPT, and any MCP-compatible client) can quote, preview, create, and track orders through a remote MCP server, in addition to the REST API above. See https://premierletters.com/developers for the human-facing quick start ("Add to Claude" / "Add to ChatGPT" instructions).

Endpoint

POST https://premierletters.com/api/mcp

Streamable HTTP, stateless — every request is a self-contained JSON-RPC 2.0 call (or batch array); there is no session handshake and no Server-Sent Events. Responses are always Content-Type: application/json.

  • GET → 405 (hint to POST)
  • OPTIONS → 204 with permissive CORS (Access-Control-Allow-Origin: *)
  • Malformed JSON body → JSON-RPC -32700 parse error, HTTP 400
  • Unknown method → JSON-RPC -32601

Authentication

initialize, ping, and tools/list work with no authentication, so directories and clients can introspect the server. tools/call requires Authorization: Bearer pl_live_... (or pl_test_...) except for five public tools (get_pricing, list_handwriting_styles, verify_address, connect_account, check_connection), which work unauthenticated and are rate-limited by IP instead. An assistant with no key should call connect_account and then check_connection (see Agent Connect). Calling an authenticated tool without a key never returns a bare 401 — it returns a normal JSON-RPC success envelope whose tool result has isError: true and text explaining how to get a key (Settings → API keys at https://premierletters.com/settings/api-keys).

Methods

MethodAuthDescription
initializenoneReturns protocolVersion, capabilities, serverInfo, and usage instructions
notifications/initializednoneAcknowledged with HTTP 202, empty body
pingnoneReturns {}
tools/listnoneReturns every tool's name, description, and JSON Schema inputSchema
tools/callsee above{ name, arguments } → { content: [{ type: "text", text }], isError? }

Tools

ToolAuthPurpose
connect_accountnoneEmail the account owner an approval link for a new key; returns request_id, poll_secret, user_code
check_connectionnonePoll a connect_account request; returns the api_key once when approved
get_pricingnoneQuote a price from char_count/letter_count + mailing_option/paper_type/product_type/output_mode/envelope_output_mode
list_handwriting_stylesnoneSame data as GET /api/v1/styles
verify_addressnoneUSPS address verification (degrades to { valid: null, reason } if USPS creds aren't configured)
list_templateskeyThe caller's saved templates
preview_letterkeyRender an SVG preview (svg_data_url + price), no order created
create_orderkeyCreate and pay for an order — same core as POST /api/v1/orders (incl. return_address, no_return_address_ack, idempotency_key), same 10/min + daily limits
get_order_statuskeyStatus + per-recipient tracking, same as GET /api/v1/orders/{id}/tracking
list_orderskeyRecent orders, same as GET /api/v1/orders
get_balancekeyPrepaid balance and top-up rules, same as GET /api/v1/balance
create_topup_linkkeyStripe Checkout link that adds balance, same as POST /api/v1/balance/topup

Discovery files

URLPurpose
/llms.txtShort, crawler/LLM-friendly overview with live pricing and key links
/llms-full.txtLonger reference: FAQ prose, REST endpoint summary, full MCP tool list
/openapi.jsonOpenAPI 3.1 document for the /api/v1 surface
/.well-known/ai-plugin.jsonLegacy OpenAI plugin manifest (harmless, superseded by MCP)
/.well-known/mcp.jsonNon-standard MCP discovery pointer some directories probe for

Submission checklist for registries (official MCP registry, Claude connectors, Smithery, Glama, PulseMCP, mcp.so): see docs/AGENT_DISTRIBUTION.md.


Webhooks

Inbound Webhooks (You Receive)

Stripe Webhooks

Premier Letters receives webhooks from Stripe for payment events.

Endpoint: POST /api/webhooks/stripe

Signature Verification:

  • Header: Stripe-Signature: t=<timestamp>,v1=<signature>
  • Compute: sha256(<timestamp>.<body>, stripe_webhook_secret)

Handled Events:

  • checkout.session.completed: Payment confirmed, order created or marked paid
  • charge.refunded: Charge refunded, order marked refunded
  • invoice.paid: Subscription invoice paid (informational)
  • invoice.payment_failed: Subscription payment failed (logged)
  • customer.subscription.deleted: Subscription cancelled, tenant suspended

Example Payload:

{
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_live_...",
      "mode": "payment",
      "customer": "cus_...",
      "customer_details": {
        "email": "user@example.com"
      },
      "amount_total": 549,
      "metadata": {
        "order_id": "550e8400-e29b-41d4-a716-446655440000",
        "user_id": "550e8400-e29b-41d4-a716-446655440111",
        "letter_count": "1"
      }
    }
  }
}

Outbound Webhooks (You Send)

Partners receive webhooks when order status changes. Configure endpoints via POST /api/v1/partner/webhooks.

Signature Verification:

  • Header: X-Signature-256: sha256=<hmac_hex>
  • Compute: hmac_sha256(webhook_secret, request_body_bytes)

Retry Policy: TODO: confirm retry logic and max attempts from partner-webhooks lib

Event Types:

  • order.confirmed: Order created and credits deducted (status: paid)
  • order.completed: Order completed (status: complete)
  • order.cancelled: Order cancelled
  • order.shipped: Order shipped
  • letter.mailed: Individual letter mailed (tracking number available)

Example Payload (order.confirmed):

{
  "event": "order.confirmed",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "order_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "paid",
    "letter_count": 1,
    "total_cents": 549,
    "product_type": "letter",
    "recipient_count": 1,
    "created_at": "2024-01-15T10:30:00Z"
  }
}

Error Codes

All API errors include a structured error response:

{
  "error": "error_code",
  "message": "Human-readable error description",
  "suggestion": "What to do next (if applicable)"
}

Authentication Errors

ErrorStatusMeaning
missing_api_key401Authorization header is missing
invalid_api_key401API key is invalid, inactive, or expired
test_mode400A test key (pl_test_) called something that would charge or write (top-up, new-card charge, Growth write)
fetch_failed500Failed to fetch user profile during auth

Validation Errors

ErrorStatusMeaning
invalid_json400Request body is not valid JSON
validation_failed422One or more validation errors (see details)
missing_url400Required field url is missing
invalid_url400URL is not a valid HTTP/HTTPS URL
invalid_events400Event types are invalid
missing_payment_method400payment_method_id is missing
invalid_payment_method400Unknown payment method

Order Errors

ErrorStatusMeaning
invalid_order_id400Order ID is not a valid UUID
order_not_found404Order does not exist or does not belong to user
order_creation_failed500Failed to create order in database
recipient_creation_failed500Failed to create recipients
no_return_address_unacknowledged400Envelope order with no return address and no no_return_address_ack: true
idempotency_key_reused422Idempotency key already used with a different request body
idempotency_in_progress409The original request with this idempotency key is still running

Payment Errors

ErrorStatusMeaning
no_payment_method402No payment method available (card_on_file requires saved card). Body carries order_id and checkout_url
payment_failed402/500Payment processing failed. On card_on_file the 402 carries order_id and checkout_url
insufficient_balance402Prepaid balance does not cover the order (balance_cents, shortfall_cents, suggestion); no order created
balance_payment_failed500Balance payment failed; order not placed, balance corrected automatically
checkout_unavailable502Stripe Checkout could not be started; no order created, safe to retry
card_declined402Card was declined by Stripe
insufficient_credits402Partner does not have sufficient credits
credit_deduction_failed500Failed to deduct credits (partner API)

Balance & Agent Connect Errors

ErrorStatusMeaning
invalid_amount / below_minimum / above_maximum400Top-up amount_cents is not an integer in range
blocked403This account cannot add balance
checkout_failed500The top-up checkout could not be started
validation_error400Agent connect body invalid (email, agent_name or mode)
rate_limited429Too many connect requests (per IP or per email), polls, or top-ups
connect_failed502The connect request could not be started
not_found404No connect request matches this id and secret

Webhook Errors

ErrorStatusMeaning
not_found404Webhook endpoint not found
creation_failed500Failed to create webhook
update_failed500Failed to update webhook
delete_failed500Failed to delete webhook

Partner API Errors

ErrorStatusMeaning
not_found404Order or key not found
not_cancellable409Order status does not allow cancellation
self_revoke409Cannot revoke the key you are currently using
missing_id400Required ID parameter is missing

Changelog

2026-10-29 (Current)

  • Documented: Cards & Postcards: card_orientation, card_write_mode (incl. the full-surface surcharge) and page_background — all accepted since earlier releases, now in the docs and /openapi.json
  • Documented: Long Messages & Pages: multi-page letters are automatic; POST /api/v1/preview returns page_count
  • Documented: Return Address: the needs_return_address hold status (2026-10-19) that non-API order channels can surface in GET /api/v1/orders
  • Noted: Shopify connector (2026-10-27): paid Shopify orders become handwritten thank-yous via Settings → Integrations — a dashboard feature, not a /v1/ endpoint; setup and behaviour in docs/SHOPIFY.md

2026-10-11

  • Added: Agent Connect: POST /api/v1/agent/connect and GET /api/v1/agent/connect/{id} (MCP: connect_account, check_connection) so an AI assistant can get a key with one email approval
  • Added: Return Address: return_address and no_return_address_ack on orders; return_address.source in responses
  • Added: Printed Output: options.output_mode and options.envelope_output_mode; options.mailing_option is honoured as sent
  • Added: Idempotency: idempotency_key body field and Idempotency-Key header on POST /api/v1/orders
  • Added: Test Mode: pl_test_ keys validate and price without creating or charging anything
  • Added: Balance: GET /api/v1/balance and POST /api/v1/balance/topup (MCP: get_balance, create_topup_link)
  • Fixed: per-endpoint rate limits now match the server (/api/v1/orders* is 10 requests/minute, not 50 or 100)

2024-01-15

  • Added: Full Partner API documentation (orders, keys, balance, fonts, webhooks)
  • Added: Partner webhook events (order.confirmed, order.completed, order.cancelled, order.shipped, letter.mailed)
  • Added: Partner order cost breakdown with detailed pricing
  • Added: Credit balance and estimated letters remaining endpoint
  • Updated: User API webhook events for clarity

2024-01-10

  • Added: API authentication and rate limiting documentation
  • Added: User order management endpoints (GET, POST, tracking)
  • Added: Webhook management endpoints (CRUD operations)
  • Added: Payment and validation endpoints

2024-01-01

  • Initial Release: /api/v1/ stable contract
  • Added: Core order creation, retrieval, and validation
  • Added: User authentication and configuration endpoints