Developer Reference

API Documentation

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

Premier Letters API Documentation

Table of Contents

  1. Overview
  2. Authentication
  3. Rate Limiting
  4. Endpoint Reference
  5. Webhooks
  6. Error Codes
  7. Changelog

Overview

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

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

Versioning

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

Content Type

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

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

HTTP Status Codes

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

Authentication

API Key Authentication

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

Authorization: Bearer pl_live_<key_suffix>

Generating an API Key

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

Key Storage

API keys are stored in the api_keys table with:

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

Example Request

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

Webhook Signature Verification

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

X-Signature-256: sha256=<hmac_hex>

Verify by computing:

hmac_sha256(webhook_secret, request_body_bytes)

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


Rate Limiting

The API enforces rate limits per API key to ensure fair usage:

  • Default limit: 1000 requests per minute
  • Response headers:
    • X-RateLimit-Limit: limit per window
    • X-RateLimit-Remaining: requests remaining
    • X-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 provided
  • invalid_api_key (401): API key is invalid or inactive
  • fetch_failed (500): Failed to fetch user profile

Example:

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

Configuration Endpoints

GET /api/v1/config

Retrieve public configuration including Stripe publishable key and pricing.

Authentication: None
Rate Limit: Unlimited

Response (200):

{
  "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 JSON
  • validation_failed (422): One or more validation errors
  • fetch_failed (500): Failed to fetch profile defaults

Field Validation:

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

Options:

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

Example:

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

Order Management Endpoints

GET /api/v1/orders

List user's orders with pagination and filtering.

Authentication: Required
Rate Limit: 100 requests/minute
Query Parameters:

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

Response (200):

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

Errors:

  • fetch_failed (500): Failed to fetch orders

Example:

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

POST /api/v1/orders

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

Authentication: Required
Rate Limit: 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 JSON
  • validation_failed (422): Validation failed (see details)
  • invalid_payment_method (400): Unknown payment method
  • no_payment_method (402): No saved card and method is card_on_file
  • payment_failed (402): Payment attempt failed
  • order_creation_failed (500): Failed to create order in database
  • recipient_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 UUID
  • order_not_found (404): Order does not exist or does not belong to user

Example:

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

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

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

Authentication: Required
Rate Limit: 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 UUID
  • order_not_found (404): Order does not exist
  • fetch_failed (500): Failed to fetch tracking

Example:

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

Payment Endpoints

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

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

Authentication: Required
Rate Limit: 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 JSON
  • missing_payment_method (400): payment_method_id is missing
  • validation_failed (422): Order validation failed
  • card_declined (402): Card was declined
  • payment_failed (402/500): Payment processing failed
  • order_creation_failed (500): Failed to create order
  • recipient_creation_failed (500): Failed to create recipients

Example:

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

Webhook Management Endpoints

GET /api/v1/webhooks

List user's webhook endpoints.

Authentication: Required
Rate Limit: 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 created
  • order.paid: Payment confirmed
  • order.in_production: Order entered production
  • order.shipped: Order shipped
  • order.delivered: Order delivered
  • order.cancelled: Order cancelled

Errors:

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

Example:

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

GET /api/v1/webhooks/{id}

Get a specific webhook endpoint.

Authentication: Required
Path Parameters:

  • id: Webhook endpoint UUID

Response (200):

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

Errors:

  • not_found (404): Webhook endpoint not found

Example:

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

PUT /api/v1/webhooks/{id}

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

Authentication: Required
Path Parameters:

  • id: Webhook endpoint UUID

Request Body (all fields optional):

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

Response (200):

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

Errors:

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

Example:

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

DELETE /api/v1/webhooks/{id}

Delete a webhook endpoint.

Authentication: Required
Path Parameters:

  • id: Webhook endpoint UUID

Response (204): No content

Errors:

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

Example:

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

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

List delivery attempts for a webhook endpoint.

Authentication: Required
Query Parameters:

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

Response (200):

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

Errors:

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

Example:

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

Partner API (Tenant-Scoped)

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

Partner Authentication

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

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

Partner Endpoints

GET /api/v1/partner/keys

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

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

Response (200):

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

Example:

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

POST /api/v1/partner/keys

Create a new partner API key.

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

Request Body:

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

Response (201):

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

Errors:

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

Example:

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

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

Revoke a partner API key.

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

  • id: API key UUID

Response (200):

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

Errors:

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

Example:

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

GET /api/v1/partner/orders

List tenant's orders with pagination and filtering.

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

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

Response (200):

{
  "orders": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "status": "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 string
  • recipients: Required, 1+ objects with first_name, last_name, address_line1, city, state, zip
  • product_type: letter, card, or postcard
  • paper_type: standard or premium
  • mailing_option: Optional (we_mail, print_send, drop_ship), defaults to system default
  • envelope_type: standard or none
  • handwriting_style: Optional font UUID, defaults to system default
  • authenticity_preset: Optional preset key
  • return_address: Optional custom return address

Errors:

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

Example:

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

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

Get a specific partner order with recipients.

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

  • id: Order UUID

Response (200):

{
  "order": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "status": "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 missing
  • not_found (404): Order not found

Example:

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

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

Cancel a 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 missing
  • not_found (404): Order not found
  • not_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 created
  • order.completed: Order completed
  • order.cancelled: Order cancelled
  • order.shipped: Order shipped
  • letter.mailed: Individual letter mailed
  • *: All events

Errors:

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

Example:

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

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

Deactivate a partner webhook.

Authentication: Required (partner key)
Path Parameters:

  • id: Webhook UUID

Response (200):

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

Errors:

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

Example:

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

Webhooks

Inbound Webhooks (You Receive)

Stripe Webhooks

Premier Letters receives webhooks from Stripe for payment events.

Endpoint: POST /api/webhooks/stripe

Signature Verification:

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

Handled Events:

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

Example Payload:

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

Outbound Webhooks (You Send)

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

Signature Verification:

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

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

Event Types:

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

Example Payload (order.confirmed):

{
  "event": "order.confirmed",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "order_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "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

ErrorStatusMeaning
missing_api_key401Authorization header is missing
invalid_api_key401API key is invalid, inactive, or expired
fetch_failed500Failed to fetch user profile during auth

Validation Errors

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

Order Errors

ErrorStatusMeaning
invalid_order_id400Order ID is not a valid UUID
order_not_found404Order does not exist or does not belong to user
order_creation_failed500Failed to create order in database
recipient_creation_failed500Failed to create recipients

Payment Errors

ErrorStatusMeaning
no_payment_method402No payment method available (card_on_file requires saved card)
payment_failed402/500Payment processing failed
card_declined402Card was declined by Stripe
insufficient_credits402Partner does not have sufficient credits
credit_deduction_failed500Failed to deduct credits (partner API)

Webhook Errors

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

Partner API Errors

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

Changelog

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