Mail by PonyDocs

Guides

Webhooks

View as Markdown

Register an HTTPS endpoint and Pony sends it a signed POST each time one of your orders changes status. You don't need to poll.

Register an endpoint#

Use a key with the webhooks:write scope (keys from the simple form have it), or add an endpoint under Account → Credits → Status webhooks.

Shell
curl --fail-with-body -sS -X POST "$PONY_API_BASE/webhooks" \
  -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$PONY_API_KEY_FILE")") \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/pony-webhooks","events":["order.mailed","order.action_needed"]}'
Field Notes
url Required. A public https URL, with no credentials or fragment. Private, loopback and link-local addresses are refused.
events Optional. The event types you want. Leave it out to receive all of them.
description Optional. Up to 200 characters.

The response includes id and secret (whsec_…). Save the secret now: it's shown only once, and you need it to verify signatures. You can have up to 10 active endpoints.

Webhook routes don't take an idempotency key, so creating the same endpoint twice registers it twice. List your endpoints and remove the extra one.

Events#

Event Sent when
order.queued The order is accepted: credits deducted, or card payment confirmed
order.preparing The order is accepted for printing
order.printed Printing is confirmed
order.mailed The letter is handed to the US Postal Service. This isn't delivery.
order.cancelled The order is cancelled. Credits are returned once if it was cancelled before preparation.
order.action_needed The order needs your attention. Read the order for details.
order.returned The Postal Service returned the letter
ping You sent a test with POST /webhooks/{id}/test. data is {"endpoint_id": …}.

An endpoint receives only changes that happen after you register it.

Payload#

Payloads never include letter text, files or addresses:

JSON
{"id":"evt_…","type":"order.mailed","created_at":"2026-10-26T15:04:05+00:00",
 "data":{"order_id":"order_…","status":"mailed","client_reference":"invoice-1042","draft_id":"draft_…","mailed_at":"2026-10-26T15:04:05+00:00"}}

Each request has these headers:

Header Value
Content-Type application/json
Pony-Event-Id The event id
Pony-Event-Type The event type
Pony-Signature t=<unix seconds>,v1=<hex signature>

Verify the signature#

v1 is an HMAC-SHA256 of the bytes <t>.<raw body>, keyed with your endpoint secret. Verify it against the raw request body, before parsing the JSON, and reject old timestamps:

Python
import hashlib, hmac, json, time

def verify_pony_webhook(body: bytes, header: str, secret: str, tolerance: int = 300) -> dict:
    """Return the event if Pony-Signature is valid and fresh; raise ValueError otherwise."""
    try:
        fields = dict(part.split('=', 1) for part in header.split(','))
        stamp, received = int(fields['t']), fields['v1']
    except (KeyError, ValueError):
        raise ValueError('Malformed Pony-Signature header')
    if abs(time.time() - stamp) > tolerance:
        raise ValueError('Stale webhook timestamp')
    expected = hmac.new(secret.encode(), str(stamp).encode() + b'.' + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        raise ValueError('Invalid webhook signature')
    return json.loads(body)

In Flask:

Python
event = verify_pony_webhook(request.get_data(), request.headers['Pony-Signature'], SECRET)

Respond and retry#

  • Respond with any 2xx within 5 seconds. Do slow work after responding.
  • Failed deliveries are retried. A timeout, an error status or a redirect (redirects are never followed) is retried with exponential backoff, from 30 seconds up to 6 hours apart, for 24 hours. After that the delivery is marked failed.
  • Expect duplicates and reordering. Delivery is at least once, and events can arrive out of order. Deduplicate on the event id, and treat GET /orders/{order_id} as the source of truth.

Manage endpoints#

Request What it does
GET /webhooks Lists your endpoints, without their secrets
POST /webhooks/{id}/test Sends a signed ping event
GET /webhooks/{id}/deliveries Lists the 50 most recent deliveries
DELETE /webhooks/{id} Stops deliveries and cancels pending ones

Each delivery shows its state (pending, delivered, failed or cancelled), attempts, next_attempt_at and an attempt_log. The log records each attempt's status code, duration and error kind: http_status, timeout, connection_error, redirect_not_followed or destination_not_allowed. Pony doesn't store your response bodies.