# Mail a PDF invoice from code

In this guide you mail one PDF, such as an invoice, notice or proposal. You'll create an API key, add credits, upload the PDF, check the proof and final price, send it and receive status webhooks.

**Try it free first:** use a `pony_test_` key and everything below runs in [test mode](/docs/test-mode.md), with no real charges or mail. A live key charges real credits and mails a real letter.

You need `curl`, `jq` and Python 3. Amounts are integer US cents (`2500` = $25.00). US addresses only.

## 1. Create an API key with a spending limit

Sign in, open **Account → Credits → API keys**, enter a name (for example `Invoice sender`) and an optional daily limit, then select **Create API key**. Copy the secret (`pony_live_…`). It's shown only once.

The key comes with its own [sending permission](/docs/authentication.md). It can create letters, proofs and quotes, send from your balance up to the daily limit, cancel before preparation, read your balance and orders, and manage webhooks. It expires in one year, and you can revoke it on the same page. For a narrower key, use the **Advanced** form; see [API keys](/docs/authentication.md).

Keep the secret out of source code, shell history, chats and URLs. Every `POST` except webhook management needs an `Idempotency-Key` (8 to 150 characters from `A–Z a–z 0–9 _ . : -`). Use the same key when you retry the same request.

```bash
umask 077
mkdir -p ~/.config/pony
pbpaste > ~/.config/pony/api-key          # or write it from your secret manager
printf 'Authorization: Bearer %s\n' "$(cat ~/.config/pony/api-key)" > ~/.config/pony/auth-header
export PONY_API_BASE="https://www.mailbypony.com/api/v1"
export PONY_API_KEY_FILE=~/.config/pony/api-key PONY_AUTH_HEADER=~/.config/pony/auth-header

api() {  # api METHOD PATH [JSON_FILE [IDEMPOTENCY_KEY]]
  if [ $# -ge 4 ]; then
    curl --fail-with-body -sS -X "$1" -H @"$PONY_AUTH_HEADER" -H 'Content-Type: application/json' \
      -H "Idempotency-Key: $4" --data-binary "@$3" "$PONY_API_BASE$2"
  elif [ $# -eq 3 ]; then
    curl --fail-with-body -sS -X "$1" -H @"$PONY_AUTH_HEADER" -H 'Content-Type: application/json' \
      --data-binary "@$3" "$PONY_API_BASE$2"
  else
    curl --fail-with-body -sS -X "$1" -H @"$PONY_AUTH_HEADER" "$PONY_API_BASE$2"
  fi
}
curl -sS "$PONY_API_BASE/catalog" | jq '.prepaid'
api GET /identity | jq '{client_id, scopes}'
```

## 2. Fund your balance

On **Account → Credits → Add credits**, choose an amount (default range $10–$500) and pay at Stripe Checkout. Credits appear after Stripe confirms payment. There are no automatic top-ups or fallback card charges.

A key with the `wallet:topup` scope (advanced form) can create the same hosted funding link from code; the link still has to be paid by a person. Save as `topup.json`:

```json
{"amount_minor":2500,"currency":"USD"}
```

```bash
api POST /wallet/topups topup.json "topup-$(date +%Y%m%d)-1" | jq '{topup_id, status, checkout_url}'
api GET /wallet | jq '{available_minor, status}'
```

## 3. Upload the PDF

Create a PDF draft with the recipient and sender addresses. Use a stable business reference in each idempotency key, so a retried request returns the same draft instead of creating a second one. Save as `draft.json`:

```json
{"title":"Invoice 1042","content_type":"pdf","recipient":{"name":"Acme Hardware, Accounts Payable","line1":"100 Example Way","line2":"Suite 200","city":"Springfield","state":"IL","postal_code":"62701","country":"US"},"sender":{"name":"Sample Studio LLC","line1":"200 Sample Street","line2":"","city":"Portland","state":"OR","postal_code":"97201","country":"US"},"content_policy_acknowledged":true}
```

```bash
api POST /drafts draft.json invoice-1042:draft > draft-result.json
DRAFT_ID="$(jq -er .draft_id draft-result.json)"
```

Upload the PDF (US Letter, 1–10 pages, flattened: no forms, scripts, attachments or links) as base64 against the draft's current `version`. The PDF is printed exactly as uploaded; Pony never extracts or re-typesets it. Leave room for the address window or let the proof show where the address block lands. The upload request has this shape:

```json
{"version":${VERSION},"kind":"pdf","name":"invoice-1042.pdf","content_base64":"${PDF_BASE64}","content_policy_acknowledged":true}
```

```bash
jq -n --argjson version "$(jq .version draft-result.json)" --rawfile pdf <(base64 < invoice-1042.pdf | tr -d '\n') \
  '{version:$version,kind:"pdf",name:"invoice-1042.pdf",content_base64:$pdf,content_policy_acknowledged:true}' > upload.json
api POST "/drafts/$DRAFT_ID/attachments" upload.json invoice-1042:pdf > upload-result.json
VERSION="$(jq -er .version upload-result.json)"   # the upload advances the version
```

## 4. Generate the exact proof

```json
{"version":${VERSION}}
```

```bash
echo "{\"version\":$VERSION}" > proof.json
api POST "/drafts/$DRAFT_ID/proofs" proof.json invoice-1042:proof > proof-result.json
jq '{proof_id, status, errors, review_url}' proof-result.json
```

A successful proof is `ready` with an `artifact_hash`. If `errors` is non-empty (for example a missing ZIP), fix the draft with `PATCH /drafts/{draft_id}` and proof again. `review_url` opens the exact print proof in the website if you want to look at it; sending with your key does not require it.

## 5. Get the final price

```json
{"draft_id":"${DRAFT_ID}","version":${VERSION},"proof_id":"${PROOF_ID}","client_reference":"invoice-1042"}
```

```bash
jq -n --arg d "$DRAFT_ID" --argjson v "$VERSION" --arg p "$(jq -er .proof_id proof-result.json)" \
  '{draft_id:$d,version:$v,proof_id:$p,client_reference:"invoice-1042"}' > quote.json
api POST /send-quotes quote.json invoice-1042:quote > quote-result.json
jq '{total_minor, tax_minor, expires_at}' quote-result.json
```

The final quote is the exact amount that will be deducted, including any tax collected, for this exact proof. `client_reference` names one business event (one invoice = one letter).

## 6. Send

No permission ID is needed: the key's own spending permission applies.

```json
{"send_intent_id":"${SEND_INTENT_ID}","quote_id":"${QUOTE_ID}","proof_id":"${PROOF_ID}","expected_total_minor":${TOTAL_MINOR},"currency":"USD"}
```

```bash
jq '{send_intent_id,quote_id,proof_id,expected_total_minor:.total_minor,currency}' quote-result.json > send.json
api POST /orders send.json invoice-1042:send | jq '{order_id, status, debited_minor}'
```

`status: "queued"` means accepted and paid from your balance, not yet printed or mailed. If the reply is lost, repeat the same request with the same key, or read `GET /requests/send_with_credits/invoice-1042:send`; you never get two letters. If the balance is short, the `402 INSUFFICIENT_FUNDS` error's `action_url` opens Account → Credits with the missing amount prefilled; nothing is charged automatically. If the daily limit would be exceeded you get `403 LIMIT_EXCEEDED`.

To cancel before preparation: `POST /orders/{order_id}/cancel` with `{"approved":true}` after explicit approval and a new idempotency key. Credits are returned once.

## 7. Receive status webhooks

Register an HTTPS endpoint once (save as `webhook.json`). Save the returned `secret`; it is shown only once.

```json
{"url":"https://example.com/pony-webhooks"}
```

```bash
api POST /webhooks webhook.json | jq '{id, secret}'
api POST "/webhooks/$WEBHOOK_ID/test" | jq .            # sends a signed "ping"
api GET "/webhooks/$WEBHOOK_ID/deliveries" | jq '.items[0]'
```

Pony then POSTs `order.queued`, `order.preparing`, `order.printed`, `order.mailed`, `order.cancelled`, `order.action_needed` and `order.returned` events with your `client_reference`. Verify the `Pony-Signature` header before trusting a payload; see [Webhooks](/docs/webhooks.md).

## The same flow in Python

Standard library only. Deterministic idempotency keys derived from the business reference make the whole function safe to rerun after a crash or timeout. Use references made of letters, digits, `.`, `_`, `:` or `-`.

```python
import base64, json, os, urllib.request
from pathlib import Path

BASE = os.environ['PONY_API_BASE']                      # https://…/api/v1
SECRET = Path(os.environ['PONY_API_KEY_FILE']).read_text().strip()

def call(method, path, body=None, key=None):
    headers = {'Authorization': 'Bearer ' + SECRET, 'Accept': 'application/json'}
    data = None
    if body is not None:
        data = json.dumps(body).encode()
        headers.update({'Content-Type': 'application/json', 'Idempotency-Key': key})
    request = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
    with urllib.request.urlopen(request, timeout=60) as response:   # HTTPError carries the JSON error
        return json.load(response)

def mail_pdf(pdf_path, reference, recipient, sender):
    """One business event (e.g. an invoice number) -> at most one letter."""
    draft = call('POST', '/drafts', {'title': reference, 'content_type': 'pdf', 'recipient': recipient,
                                     'sender': sender, 'content_policy_acknowledged': True}, reference + ':draft')
    draft = call('POST', f"/drafts/{draft['draft_id']}/attachments", {
        'version': draft['version'], 'kind': 'pdf', 'name': Path(pdf_path).name,
        'content_base64': base64.b64encode(Path(pdf_path).read_bytes()).decode(),
        'content_policy_acknowledged': True}, reference + ':pdf')
    proof = call('POST', f"/drafts/{draft['draft_id']}/proofs", {'version': draft['version']}, reference + ':proof')
    if proof.get('errors'):
        raise RuntimeError(proof['errors'])
    quote = call('POST', '/send-quotes', {'draft_id': draft['draft_id'], 'version': draft['version'],
                 'proof_id': proof['proof_id'], 'client_reference': reference}, reference + ':quote')
    return call('POST', '/orders', {'send_intent_id': quote['send_intent_id'], 'quote_id': quote['quote_id'],
                'proof_id': quote['proof_id'], 'expected_total_minor': quote['total_minor'], 'currency': 'USD'},
                reference + ':send')
```

For scheduled sends that must survive lost replies across processes, see the [cron example](/docs/scheduling.md). Full schemas: [OpenAPI](/api/v1/openapi.json) and the [API reference](/docs/api.md). Error codes are in [Errors](/docs/errors.md).
