API Documentation

The Premier Letters API allows you to programmatically create and manage written letter orders. All API access is over HTTPS, and all request and response bodies are JSON.

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

Authentication

All API requests require authentication using an API key. Include your API key in theAuthorization header:

Authorization: Bearer pl_live_xxxxxxxxxxxxx

Generate API keys in your Settings, or let an AI assistant request one by email (see Agent Connect).

Live keys start pl_live_ and create real orders. Test keys start pl_test_ and never create or charge anything (see Test Mode).

Rate Limits

API requests are rate limited per API key. Limits vary by endpoint:

EndpointPer-Minute Limit
/orders (POST)10 requests
/validate100 requests
/webhooks30 requests
Other endpoints60 requests

Daily limits: 500 orders per day, 5,000 total API calls per day.

Rate limit information is included in response headers:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1703203200
X-RateLimit-Daily-Limit: 5000
X-RateLimit-Daily-Remaining: 4987
X-RateLimit-Daily-Reset: 1703289600

Errors

The API uses conventional HTTP response codes. Errors include a JSON body with details:

{
  "error": "invalid_api_key",
  "message": "Invalid or inactive API key",
  "suggestion": "Check that your API key is correct and active."
}
CodeDescription
400Bad Request - Invalid parameters
401Unauthorized - Invalid or missing API key
402Payment Required - the card on file could not be charged. The order was still created (pending_payment) and the body carries order_id and checkout_url; send the customer there. Do not resubmit the request, or you will create a second order. With payment.method: "balance" a 402 insufficient_balance carries balance_cents, shortfall_cents and a suggestion linking to the top-up page; no order is created and nothing is charged.
404Not Found - Resource doesn't exist
422Validation Error - Invalid data
429Rate Limited - Too many requests
500Server Error - Something went wrong
502checkout_unavailable - Stripe Checkout could not be started. No order was created and nothing was charged; safe to retry.

Create Order

Create a new order for written letters.

POST/orders

Request Body

{
  "message": "Dear {{first_name}},\n\nThank you for your business...",
  "recipients": [
    {
      "first_name": "John",
      "last_name": "Doe",
      "address_line1": "123 Main St",
      "city": "New York",
      "state": "NY",
      "zip": "10001"
    }
  ],
  "options": {
    "handwriting_style": "elegant",
    "paper_type": "cream",
    "product_type": "letter"
  },
  "payment": {
    "method": "card_on_file"
  }
}

Parameters

FieldTypeDescription
messagestringLetter content. Use {{field}} for personalization.
recipientsarrayList of recipient objects (max 100).
options.handwriting_stylestringStyle: elegant, casual, formal, friendly
options.paper_typestringPaper: cream, white, kraft
payment.methodstringcard_on_file, checkout_session, invoice (not yet supported; 400), or balance (prepaid balance, small-order handling fee waived; 402 insufficient_balance when short)
options.output_modestringhandwritten (default), printed_bw or printed_color. See Printed Output.
options.envelope_output_modestring | nullnull (default, hand-addressed), printed_bw or printed_color
options.mailing_optionstringwe_mail, you_mail_stamped or you_mail_no_stamp. Omit to use your account default.
return_addressobjectEnvelope return address. Omit to use your account default. See Return Address.
no_return_address_ackbooleanSend with no return address when there is none to use
idempotency_keystring1-255 characters; a retry with the same key returns the original order. Also accepted as the Idempotency-Key header. See Idempotency.
skip_invalidbooleanSkip invalid recipients instead of failing

Response

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

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 the order moves to paid on its own. Do not resubmit: the card has already been charged.

Orders under 5 pieces carry a per-piece small-order handling fee (see small_order_handling in GET /config). It is included in total_cents and waived for balance payments.

402: card on file could not be charged

The order is created anyway, awaiting payment. Send the customer to checkout_url (it pays exactly this order). Do not resubmit the request.

{
  "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_..."
}

402: insufficient balance

With payment.method: "balance", when the prepaid balance does not cover the order nothing is charged and no order is kept. If the balance payment fails for another reason the response is a 500 balance_payment_failed: the order is not placed, and if your balance changed it is corrected automatically.

{
  "error": "insufficient_balance",
  "message": "Your prepaid balance does not cover this order.",
  "suggestion": "Top up at https://www.premierletters.com/settings/billing",
  "balance_cents": 300,
  "shortfall_cents": 254
}

Get Order

Retrieve details of a specific order including recipient status and tracking.

GET/orders/{order_id}

Response

{
  "order": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "mailed",
    "letter_count": 1,
    "total_cents": 554,
    "shipped_date": "2024-01-15T10:30:00Z"
  },
  "recipients": [
    {
      "id": "rec_123",
      "name": "John Doe",
      "status": "mailed",
      "tracking_number": "9400111899223..."
    }
  ],
  "summary": {
    "total": 1,
    "pending": 0,
    "mailed": 1
  }
}

Validate Order

Validate an order without creating it. Useful for testing integrations and getting pricing.

POST/validate

Request body is the same as Create Order. Response includes validation status and pricing:

{
  "valid": true,
  "letter_count": 10,
  "total_cents": 5540,
  "total_formatted": "55.40",
  "message": "Order is valid. 10 letters at 5.54 each = 55.40 total."
}

Return Address

Every order with an envelope needs a return address. Pass return_address (name, address_line1, city, two-letter state, zip; optional company and address_line2), or omit it to use the default return address saved in your account. With neither, the order is refused with 400 no_return_address_unacknowledged unless you set no_return_address_ack: true (undeliverable mail is then destroyed).

The response reports which address was used: return_address.source is request, profile or none.

Printed Output

Letters are pen-written by default. Set options.output_mode to printed_bw or printed_color to print the letter body instead (the envelope stays hand-addressed), and options.envelope_output_mode to printed_bw or printed_color to print the envelope. Quote either with POST /validate before ordering. options.mailing_option is honoured as sent; omit it to use your account default.

Idempotency

Send an Idempotency-Key header (or an idempotency_key body field, 1-255 characters) with every POST /orders. Retrying with the same key and body returns the original order with 200 and idempotent_replay: true, so a network retry never creates or charges a second order.

  • Same key, different body: 422 idempotency_key_reused. Use a new key.
  • Same key while the first request is still running: 409 idempotency_in_progress. Retry in a few seconds.

Agent Connect

An AI assistant with no key can get one with a single email approval from the account owner. No authentication is needed for these two calls.

  1. POST /agent/connect with { "email", "agent_name" } (optional mode: "test"). Returns 202 with request_id, poll_url, poll_secret, user_code and expires_at. Show the user the user_code; the approval page shows the same code.
  2. Poll GET /agent/connect/{request_id}?secret=<poll_secret> every 10-30 seconds. Status is pending, denied, expired or claimed, or, exactly once, approved with the api_key.
  3. Use the key as Authorization: Bearer <api_key>.
curl -X POST https://www.premierletters.com/api/v1/agent/connect \
  -H "Content-Type: application/json" \
  -d '{"email": "owner@example.com", "agent_name": "Claude"}'

Requests are limited per IP and per email address (429 rate_limited); an invalid agent_name is a 400 validation_error. MCP clients use the connect_account and check_connection tools.

Test Mode

Keys starting pl_test_ run the same validation, pricing and return-address checks as live keys but never create, charge or send anything. POST /orders answers 201 with test: true and a test_ order id; order lists are empty; the balance reads 0; top-ups, new-card charges and Growth writes answer 400 test_mode. Webhook endpoints can be managed with either key.

Balance

Pay with payment.method: "balance" to draw on your prepaid balance. Paying from the balance waives the handling fee on orders under 5 pieces; a short balance is a 402 insufficient_balance and nothing is created or charged.

  • GET /balance: balance_cents, min_cents, max_cents, bonus tiers and handling_fee_waived_when_paying_from_balance.
  • POST /balance/topup with { "amount_cents" }: a Stripe checkout_url for a person to pay, with amount_cents and bonus_cents. The credit lands when it is paid. Errors: invalid_amount, below_minimum, above_maximum, test_mode (400), blocked (403), rate_limited (429).

Webhook Events

Receive real-time notifications when order status changes.

EventDescription
order.createdOrder was created (pending payment)
order.paidPayment received, order queued
order.in_productionLetters being written
order.shippedLetters mailed
order.deliveredDelivery confirmed by USPS
order.cancelledOrder cancelled or refunded

Webhook Payload

{
  "event": "order.shipped",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "order_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "mailed",
    "letter_preview": "Dear John,\n\nThank you for your business...",
    "recipient_count": 10,
    "total": 34.90,
    "tracking_numbers": ["9400111899223..."],
    "source": "api"
  }
}

Manage Webhook Endpoints

List Endpoints

GET/webhooks

Create Endpoint

POST/webhooks
{
  "url": "https://your-app.com/webhooks/premier-letters",
  "events": ["order.paid", "order.shipped"]
}

Response includes a signing secret - store it securely, it's only shown once.

Update Endpoint

PUT/webhooks/{endpoint_id}

Delete Endpoint

DELETE/webhooks/{endpoint_id}

Signature Verification

All webhooks include a signature header for verification. Always verify signatures in production.

Headers

X-Premier-Letters-SignatureHMAC-SHA256 signature
X-Premier-Letters-EventEvent type
X-Premier-Letters-Delivery-IdUnique delivery ID

Verification Example (Node.js)

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// In your webhook handler:
app.post('/webhooks/premier-letters', (req, res) => {
  const signature = req.headers['x-premier-letters-signature'];
  const payload = JSON.stringify(req.body);

  if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process webhook...
  res.status(200).json({ received: true });
});

Important: Always return a 2xx response within 10 seconds. Failed deliveries are logged but not retried automatically.

Need Help?

Have questions or need assistance with the API?

Contact Support