Premier Letters API Documentation
Table of Contents
- Overview
- Authentication
- Rate Limiting
- 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>
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 |
|---|---|
POST /api/v1/orders | 10 |
POST /api/v1/validate | 100 |
/api/v1/webhooks* | 30 |
Everything else (/api/v1/*, /api/mcp) | 60 (default) |
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
}
Endpoint Reference
Authentication Endpoints
GET /api/v1/auth/verify
Verify API key and retrieve user account information.
Authentication: Required
Rate Limit: 10 requests/minute per key
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: 100 requests/minute
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: 100 requests/minute
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: 50 requests/minute
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"
},
"payment": {
"method": "checkout_session",
"return_url": "https://yourapp.com/order-complete"
},
"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"
}
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"
}
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)invalid_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" \
-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: 100 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: 100 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: 50 requests/minute
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: 100 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: 50 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_... except for three public, read-only
tools (get_pricing, list_handwriting_styles, verify_address), which work unauthenticated and are
rate-limited by IP instead. 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 |
|---|---|---|
get_pricing | none | Quote a price from char_count/letter_count + mailing_option/paper_type/product_type |
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, 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 |
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 |
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 |
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) |
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
2024-01-15 (Current)
- 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