Premier Letters API Documentation
Table of Contents
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 — insufficient payment method 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 to ensure fair usage:
- Default limit: 1000 requests per minute
- Response headers:
X-RateLimit-Limit: limit per windowX-RateLimit-Remaining: requests remainingX-RateLimit-Reset: Unix timestamp of reset
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):
{
"stripe_publishable_key": "pk_live_...",
"price_per_letter_cents": 499
}
Example:
curl -X GET https://premierletters.com/api/v1/config
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"
}
}
Response (200):
{
"valid": true,
"letter_count": 1,
"total_cents": 499,
"total_formatted": "$4.99",
"options": {
"handwriting_style": "550e8400-e29b-41d4-a716-446655440000",
"paper_type": "standard",
"product_type": "letter",
"authenticity_preset": "natural"
},
"message": "Order is valid. 1 letter at $4.99 each = $4.99 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"
}
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"
}
Payment Methods:
card_on_file: Charge a saved card. Returns 402 if no card available; include checkout_url in error.checkout_session: Create Stripe Checkout session. Returns checkout_url in response.invoice: Not yet supported (returns 400).
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): No saved card and method is card_on_filepayment_failed(402): Payment attempt failedorder_creation_failed(500): Failed to create order in databaserecipient_creation_failed(500): Failed to create recipients
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": "pending",
"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": "pending",
"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"
}
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": "pending",
"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 pending partner order 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 'pending'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"
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 (status: pending)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": "pending",
"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) |
payment_failed | 402/500 | Payment processing failed |
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