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, 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. 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.
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.
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:
{"amount_minor":2500,"currency":"USD"}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:
{"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}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:
{"version":${VERSION},"kind":"pdf","name":"invoice-1042.pdf","content_base64":"${PDF_BASE64}","content_policy_acknowledged":true}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 version4. Generate the exact proof#
{"version":${VERSION}}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.jsonA 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#
{"draft_id":"${DRAFT_ID}","version":${VERSION},"proof_id":"${PROOF_ID}","client_reference":"invoice-1042"}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.jsonThe 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.
{"send_intent_id":"${SEND_INTENT_ID}","quote_id":"${QUOTE_ID}","proof_id":"${PROOF_ID}","expected_total_minor":${TOTAL_MINOR},"currency":"USD"}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.
{"url":"https://example.com/pony-webhooks"}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.
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 -.
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. Full schemas: OpenAPI and the API reference. Error codes are in Errors.