Premier Letters API Documentation
Table of Contents
- Overview
- Authentication
- Rate Limiting
- Agent Connect
- Test Mode
- Return Address
- Printed Output
- Cards & Postcards
- Long Messages & Pages
- Idempotency
- Balance
- Endpoint Reference
- MCP Server & Agent Discovery
- Webhooks
- Error Codes
- 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
| Status | Meaning |
|---|---|
200 | Success — request completed as expected. |
201 | Created — a new resource was successfully created. |
202 | Accepted — request accepted for processing (e.g., draft order). |
204 | No Content — successful request with no response body. |
400 | Bad Request — validation or malformed request. |
402 | Payment Required — no usable payment method, a failed charge, or insufficient prepaid balance or credits. |
404 | Not Found — resource does not exist. |
409 | Conflict — operation cannot be performed in current state. |
422 | Unprocessable Entity — validation failed (see validation_failed error). |
429 | Too Many Requests — rate limit exceeded. |
500 | Internal 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
- Log in to your Premier Letters account
- Navigate to Settings → API Keys
- Click "Generate New Key"
- Copy the full key (displayed only once)
- 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:
| Endpoint | Per-minute limit |
|---|---|
/api/v1/orders* (POST /api/v1/orders, and the GET order list, detail and tracking) | 10 |
POST /api/v1/validate | 100 |
/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 windowX-RateLimit-Remaining: requests remainingX-RateLimit-Reset: Unix timestamp of resetX-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:
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 returneduser_code: the approval page shows the same code so they can confirm the request is theirs.- Poll
GET /api/v1/agent/connect/{request_id}?secret=<poll_secret>every 10-30 seconds untilstatusisapproved. The response then carriesapi_key, exactly once; save it. - 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) ortestfor apl_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 answerclaimed.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:
| Endpoint | Test-key behaviour |
|---|---|
POST /api/v1/orders | 201 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/config | Same as live |
GET /api/v1/orders | Empty list with "test": true |
GET /api/v1/orders/{id}, /tracking | 404 order_not_found (test keys never create orders) |
GET /api/v1/balance | Same fields, balance_cents: 0, "test": true |
POST /api/v1/balance/topup, POST /api/v1/payment/charge-new-card, Growth record writes | 400 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:
no_return_address_ack: true(envelope orders): no return address, even when the request also carries areturn_addressor the account has a default (2026-10-21). Undeliverable mail is then destroyed;- otherwise
return_addressin the request body, if given; - otherwise the account's default return address (Settings → return address);
- 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_bworprinted_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_bworprinted_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) orlandscape(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;reversewrites the other half;fullwrites 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/configpublishes the live meter;POST /api/v1/validatequotes any message exactly). Messages above the meter's maximum are refused with avalidation_failedthat states the limit. - Fit:
POST /api/v1/previewreturnspage_countalongside the rendered SVG, so an agent can tell a caller "this letter runs to 2 pages" before ordering./validatedoes 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_keywins. - A repeat with the same key and the same request body returns the original order with HTTP
200and"idempotent_replay": true. Nothing new is created or charged. If the order is still awaiting payment, the replay carries the original order'scheckout_url(the same Checkout session, not a new one); once that session has expired or the order is no longerpending_payment, the replay has nocheckout_url. - The same key with any difference in the request body other than
idempotency_keyis422 idempotency_key_reused. Omitted defaults count as their default (payment.methodcheckout_session,no_return_address_ackandskip_invalidfalse,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_ordertool takes the sameidempotency_keyargument.
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 providedinvalid_api_key(401): API key is invalid or inactivefetch_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):messagefield is requiredrender_failed(500): Invalidhandwriting_styleorproduct_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 JSONvalidation_failed(422): One or more validation errorsfetch_failed(500): Failed to fetch profile defaults
Field Validation:
message: Required, 1-7500 charactersrecipients: Array of 1-100 objectsfirst_name: Required, max 100 charslast_name: Required, max 100 charsaddress_line1: Required, max 255 charsaddress_line2: Optional, max 255 charscity: Required, max 100 charsstate: Required, 2-letter US state codezip: Required, 5-digit or ZIP+4 formatcustom_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 20offset: Default 0status: 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
}
return_address/no_return_address_ack: see Return Address.options.output_mode/options.envelope_output_mode: see Printed Output.idempotency_key: see Idempotency.
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, aspending_payment, and the response is a 402 carryingorder_idand acheckout_urlthat pays exactly that order. Do not resubmit the request after a 402: that creates a second order. Send the customer tocheckout_url, or pollGET /api/v1/orders/{order_id}.checkout_session: The order is created aspending_paymentand a Stripe Checkout session is opened for it. Returns 202 withcheckout_url; the order moves topaidwhen 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 500balance_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 400payment_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 JSONvalidation_failed(422): Validation failed (see details)no_return_address_unacknowledged(400): Envelope order with noreturn_address, no account default and nono_return_address_ack: trueidempotency_key_reused(422): Thisidempotency_keywas already used with a different request bodyidempotency_in_progress(409): The original request with thisidempotency_keyis still being processed; retry in a few secondsinvalid_payment_method(400): Unknown payment methodno_payment_method(402): Method is card_on_file and there is no saved card. The order exists aspending_payment; the body includesorder_idandcheckout_url. Do not retry.payment_failed(402): The card-on-file charge failed. The order exists aspending_payment; the body includesorder_idandcheckout_url. Do not retry.insufficient_balance(402,balancemethod): Balance too low; body includesbalance_cents,shortfall_centsand asuggestionlinking to the top-up page. No order is created and nothing is charged.balance_payment_failed(500,balancemethod): 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 UUIDorder_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 UUIDorder_not_found(404): Order does not existfetch_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 JSONmissing_payment_method(400): payment_method_id is missingvalidation_failed(422): Order validation failedcard_declined(402): Card was declinedpayment_failed(402/500): Payment processing failedorder_creation_failed(500): Failed to create orderrecipient_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 createdorder.paid: Payment confirmedorder.in_production: Order entered productionorder.shipped: Order shippedorder.delivered: Order deliveredorder.cancelled: Order cancelled
Errors:
invalid_json(400): Request body is not valid JSONmissing_url(400): url field is missinginvalid_url(400): url is not a valid HTTP/HTTPS URLinvalid_events(400): events contains invalid event typescreation_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 JSONinvalid_url(400): url is not a valid HTTP/HTTPS URLinvalid_events(400): events contains invalid event typesno_updates(400): No valid fields providednot_found(404): Webhook endpoint not foundupdate_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 founddelete_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 50offset: 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 foundfetch_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 hashkey_prefix: display prefixpermissions: array of permission stringsis_active: whether key is validexpires_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 JSONvalidation_error(400): name is required or permission is invalidcreation_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 missingnot_found(404): API key not foundself_revoke(409): Cannot revoke the key you are currently usingrevocation_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 1limit: Max 100, default 25status: Optional filtersort: Field name (created_at, updated_at, total_cents, letter_count, status), default created_atorder: '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 stringrecipients: Required, 1+ objects with first_name, last_name, address_line1, city, state, zipproduct_type: letter, card, or postcardpaper_type: standard or premiummailing_option: Optional (we_mail, print_send, drop_ship), defaults to system defaultenvelope_type: standard or nonehandwriting_style: Optional font UUID, defaults to system defaultauthenticity_preset: Optional preset keyreturn_address: Optional custom return address
Errors:
invalid_json(400): Request body is not valid JSONvalidation_error(400): One or more validation errorsinsufficient_credits(402): Not enough credits (credit tenants only)order_creation_failed(500): Failed to create orderrecipient_creation_failed(500): Failed to create recipientscredit_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 missingnot_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 missingnot_found(404): Order not foundnot_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 createdorder.completed: Order completedorder.cancelled: Order cancelledorder.shipped: Order shippedletter.mailed: Individual letter mailed*: All events
Errors:
invalid_json(400): Request body is not valid JSONvalidation_error(400): Invalid URL or event typecreation_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 missingnot_found(404): Webhook not founddeactivation_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→204with permissive CORS (Access-Control-Allow-Origin: *)- Malformed JSON body → JSON-RPC
-32700parse error, HTTP400 - 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
| Method | Auth | Description |
|---|---|---|
initialize | none | Returns protocolVersion, capabilities, serverInfo, and usage instructions |
notifications/initialized | none | Acknowledged with HTTP 202, empty body |
ping | none | Returns {} |
tools/list | none | Returns every tool's name, description, and JSON Schema inputSchema |
tools/call | see above | { name, arguments } → { content: [{ type: "text", text }], isError? } |
Tools
| Tool | Auth | Purpose |
|---|---|---|
connect_account | none | Email the account owner an approval link for a new key; returns request_id, poll_secret, user_code |
check_connection | none | Poll a connect_account request; returns the api_key once when approved |
get_pricing | none | Quote a price from char_count/letter_count + mailing_option/paper_type/product_type/output_mode/envelope_output_mode |
list_handwriting_styles | none | Same data as GET /api/v1/styles |
verify_address | none | USPS address verification (degrades to { valid: null, reason } if USPS creds aren't configured) |
list_templates | key | The caller's saved templates |
preview_letter | key | Render an SVG preview (svg_data_url + price), no order created |
create_order | key | Create 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_status | key | Status + per-recipient tracking, same as GET /api/v1/orders/{id}/tracking |
list_orders | key | Recent orders, same as GET /api/v1/orders |
get_balance | key | Prepaid balance and top-up rules, same as GET /api/v1/balance |
create_topup_link | key | Stripe Checkout link that adds balance, same as POST /api/v1/balance/topup |
Discovery files
| URL | Purpose |
|---|---|
/llms.txt | Short, crawler/LLM-friendly overview with live pricing and key links |
/llms-full.txt | Longer reference: FAQ prose, REST endpoint summary, full MCP tool list |
/openapi.json | OpenAPI 3.1 document for the /api/v1 surface |
/.well-known/ai-plugin.json | Legacy OpenAI plugin manifest (harmless, superseded by MCP) |
/.well-known/mcp.json | Non-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 paidcharge.refunded: Charge refunded, order marked refundedinvoice.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 cancelledorder.shipped: Order shippedletter.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
| Error | Status | Meaning |
|---|---|---|
missing_api_key | 401 | Authorization header is missing |
invalid_api_key | 401 | API key is invalid, inactive, or expired |
test_mode | 400 | A test key (pl_test_) called something that would charge or write (top-up, new-card charge, Growth write) |
fetch_failed | 500 | Failed to fetch user profile during auth |
Validation Errors
| Error | Status | Meaning |
|---|---|---|
invalid_json | 400 | Request body is not valid JSON |
validation_failed | 422 | One or more validation errors (see details) |
missing_url | 400 | Required field url is missing |
invalid_url | 400 | URL is not a valid HTTP/HTTPS URL |
invalid_events | 400 | Event types are invalid |
missing_payment_method | 400 | payment_method_id is missing |
invalid_payment_method | 400 | Unknown payment method |
Order Errors
| Error | Status | Meaning |
|---|---|---|
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 |
order_creation_failed | 500 | Failed to create order in database |
recipient_creation_failed | 500 | Failed to create recipients |
no_return_address_unacknowledged | 400 | Envelope order with no return address and no no_return_address_ack: true |
idempotency_key_reused | 422 | Idempotency key already used with a different request body |
idempotency_in_progress | 409 | The original request with this idempotency key is still running |
Payment Errors
| Error | Status | Meaning |
|---|---|---|
no_payment_method | 402 | No payment method available (card_on_file requires saved card). Body carries order_id and checkout_url |
payment_failed | 402/500 | Payment processing failed. On card_on_file the 402 carries order_id and checkout_url |
insufficient_balance | 402 | Prepaid balance does not cover the order (balance_cents, shortfall_cents, suggestion); no order created |
balance_payment_failed | 500 | Balance payment failed; order not placed, balance corrected automatically |
checkout_unavailable | 502 | Stripe Checkout could not be started; no order created, safe to retry |
card_declined | 402 | Card was declined by Stripe |
insufficient_credits | 402 | Partner does not have sufficient credits |
credit_deduction_failed | 500 | Failed to deduct credits (partner API) |
Balance & Agent Connect Errors
| Error | Status | Meaning |
|---|---|---|
invalid_amount / below_minimum / above_maximum | 400 | Top-up amount_cents is not an integer in range |
blocked | 403 | This account cannot add balance |
checkout_failed | 500 | The top-up checkout could not be started |
validation_error | 400 | Agent connect body invalid (email, agent_name or mode) |
rate_limited | 429 | Too many connect requests (per IP or per email), polls, or top-ups |
connect_failed | 502 | The connect request could not be started |
not_found | 404 | No connect request matches this id and secret |
Webhook Errors
| Error | Status | Meaning |
|---|---|---|
not_found | 404 | Webhook endpoint not found |
creation_failed | 500 | Failed to create webhook |
update_failed | 500 | Failed to update webhook |
delete_failed | 500 | Failed to delete webhook |
Partner API Errors
| Error | Status | Meaning |
|---|---|---|
not_found | 404 | Order or key not found |
not_cancellable | 409 | Order status does not allow cancellation |
self_revoke | 409 | Cannot revoke the key you are currently using |
missing_id | 400 | Required ID parameter is missing |
Changelog
2026-10-29 (Current)
- Documented: Cards & Postcards:
card_orientation,card_write_mode(incl. the full-surface surcharge) andpage_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/previewreturnspage_count - Documented: Return Address: the
needs_return_addresshold status (2026-10-19) that non-API order channels can surface inGET /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 indocs/SHOPIFY.md
2026-10-11
- Added: Agent Connect:
POST /api/v1/agent/connectandGET /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_addressandno_return_address_ackon orders;return_address.sourcein responses - Added: Printed Output:
options.output_modeandoptions.envelope_output_mode;options.mailing_optionis honoured as sent - Added: Idempotency:
idempotency_keybody field andIdempotency-Keyheader onPOST /api/v1/orders - Added: Test Mode:
pl_test_keys validate and price without creating or charging anything - Added: Balance:
GET /api/v1/balanceandPOST /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