> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oneshotagent.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Physical Mail

> Send a letter or a postcard to a US address: upload the artwork, review a proof and a price, approve it, and send.

Physical Mail prints and posts a letter or a 4x6 postcard to a US address. You upload the artwork, OneShot renders a proof and quotes a price, a person approves that exact piece at that exact price, and the piece is sent.

<Warning>
  Physical Mail is built and is not yet set up in production. Until it is, every request except reading or cancelling an existing order returns `503 physical_mail_not_configured`. The [roadmap](/roadmap) tracks its status.
</Warning>

## How it works

<Steps>
  <Step title="Upload the artwork">
    `POST /assets` with the file as the raw body. A letter is one PDF. A postcard is a front and a back.
  </Step>

  <Step title="Preview">
    `POST /preview` with the two addresses and the artwork. OneShot checks both addresses, counts the pages, and starts rendering a proof. Nothing is charged.
  </Step>

  <Step title="Wait for the quote">
    Poll `GET /quotes/{id}` until `status` is `ready`. The quote then carries the proof and `total_usdc`. A quote expires 30 minutes after it is created.
  </Step>

  <Step title="Approve">
    A person looks at the proof, the addresses, and the price. `POST /approve` records that decision against the quote's `input_hash` and `total_usdc`, so a changed piece or price cannot be sent under an old approval.
  </Step>

  <Step title="Send">
    Save an idempotency key first, then `POST /send`. The order is paid from credits, or by x402 if there are none.
  </Step>

  <Step title="Follow the order">
    `GET /orders/{id}` returns the order and its delivery events. If the response to `/send` was lost, `GET /orders/recover?key=` finds the order by its idempotency key.
  </Step>
</Steps>

## Authentication

Every route needs a wallet session: `X-Agent-ID` and a signed `x-agent-proof`, which the SDK adds. A failed proof returns `401 invalid_agent_proof`. The limit is 60 requests a minute per agent.

## Endpoints

All paths are under `https://win.oneshotagent.com/v1/tools/physical-mail`.

| Method and path | What it does | Returns |
| - | - | - |
| `POST /assets` | Upload artwork. Raw body, `Content-Type` of `application/pdf`, `image/png`, or `image/jpeg`, up to 20 MB | `asset_id`, `content_hash`, `mime_type` |
| `POST /validate-address` | Check one address | `deliverable`, `deliverability`, `address` |
| `POST /preview` | Start a quote for `to`, `from`, and `artwork` | A quote |
| `GET /quotes/{id}` | Read a quote | A quote |
| `POST /approve` | Approve one quote at one price | `approval_id`, `quote_id` |
| `POST /send` | Send an approved quote | `202` and an order |
| `GET /orders/{id}` | Read an order | An order |
| `GET /orders/recover?key=` | Find an order by idempotency key | An order |
| `POST /orders/{id}/cancel` | Ask to cancel an order | `202` and an order |

### Address

<ParamField body="name" type="string" required>
  Recipient or sender name, up to 100 characters.
</ParamField>

<ParamField body="address_line1" type="string" required>
  Street address, up to 200 characters.
</ParamField>

<ParamField body="address_line2" type="string">
  Apartment, suite, or unit.
</ParamField>

<ParamField body="address_city" type="string" required>
  City.
</ParamField>

<ParamField body="address_state" type="string" required>
  Two-letter state code, for example `OH`.
</ParamField>

<ParamField body="address_zip" type="string" required>
  ZIP code, five digits or ZIP+4.
</ParamField>

<ParamField body="address_country" type="string">
  `US`. No other value is accepted.
</ParamField>

### Artwork

One of:

* A letter: `{ "kind": "letter", "file": "<asset_id>", "color": false, "double_sided": false }`. The file must be a PDF of 1 to 60 pages.
* A postcard: `{ "kind": "postcard", "front": "<asset_id>", "back": "<asset_id>" }`. Each side can be a PDF, PNG, or JPEG.

### Approve

<ParamField body="quote_id" type="string" required>
  The quote to approve.
</ParamField>

<ParamField body="input_hash" type="string" required>
  The quote's `input_hash`. It ties the approval to the exact addresses and artwork.
</ParamField>

<ParamField body="total_usdc" type="string" required>
  The quote's `total_usdc`. It ties the approval to the exact price.
</ParamField>

<ParamField body="approved" type="boolean" required>
  Must be `true`.
</ParamField>

### Send

<ParamField header="Idempotency-Key" type="string" required>
  A key you have saved before sending, 1 to 255 characters. It never expires. Sending again with the same key and body returns the same order with `Idempotent-Replay: true`.
</ParamField>

<ParamField header="X-Max-Cost-USDC" type="string">
  Refuse the send if the quote is higher than this.
</ParamField>

<ParamField header="X-Approval-Id" type="string">
  Releases a send that an [action policy](/api-reference/approvals/overview) held. This is separate from the quote's `approval_id`.
</ParamField>

<ParamField body="quote_id" type="string" required>
  The approved quote.
</ParamField>

<ParamField body="approval_id" type="string" required>
  The `approval_id` returned by `/approve`.
</ParamField>

<Snippet file="audit-context-params.mdx" />

## Response

### Quote

<ResponseField name="quote_id" type="string">
  The quote's ID.
</ResponseField>

<ResponseField name="status" type="string">
  `rendering` or `ready`.
</ResponseField>

<ResponseField name="input_hash" type="string">
  A hash of the normalized addresses and artwork. Pass it to `/approve`.
</ResponseField>

<ResponseField name="preview" type="object">
  `url` of the proof (null while rendering) and `thumbnails`.
</ResponseField>

<ResponseField name="total_usdc" type="string | null">
  The price, null until the quote is ready. It is the print and postage cost plus a service fee.
</ResponseField>

<ResponseField name="service_fee_usdc" type="string | null">
  The service fee inside the total.
</ResponseField>

<ResponseField name="expires_at" type="string">
  When the quote stops being sendable.
</ResponseField>

### Order

<ResponseField name="order_id" type="string">
  The order's ID.
</ResponseField>

<ResponseField name="order_status" type="string">
  `pending`, `submitting`, `accepted`, `canceled`, `failed`, or `needs_reconciliation`.
</ResponseField>

<ResponseField name="fulfillment_status" type="string">
  Where the piece is. See [delivery states](#delivery-states).
</ResponseField>

<ResponseField name="payment_status" type="string">
  The state of the payment for this order.
</ResponseField>

<ResponseField name="total_usdc" type="string">
  What the order cost.
</ResponseField>

<ResponseField name="signed_receipt" type="object | null">
  The [signed receipt](/api-reference/analytics/receipts), null until execution is final.
</ResponseField>

<ResponseField name="events" type="array">
  Delivery events, each with `event_id`, `event_type`, and `occurred_at`.
</ResponseField>

<ResponseField name="cancel_before" type="string">
  The time after which the piece can no longer be cancelled.
</ResponseField>

<ResponseField name="refunded_at" type="string | null">
  When the order was refunded, if it was.
</ResponseField>

<ResponseField name="delivery_proves_readership" type="boolean">
  Always `false`. Delivery is a postal observation and never evidence that anyone read the piece.
</ResponseField>

## SDK usage

```typescript TypeScript theme={null}
const asset = await agent.physicalMail.uploadArtwork(pdfBytes, 'application/pdf');

let quote = await agent.physicalMail.preview({
  to,
  from,
  artwork: { kind: 'letter', file: asset.asset_id },
});

// Poll until the proof and the price are ready.
while (quote.status !== 'ready') {
  await new Promise(resolve => setTimeout(resolve, 2000));
  quote = await agent.physicalMail.getQuote(quote.quote_id);
}

// Show quote.preview.url and quote.total_usdc to the person who approves.
// Call approve from their approval handler, never automatically after a preview.
const approval = await agent.physicalMail.approve({
  quote_id: quote.quote_id,
  input_hash: quote.input_hash,
  total_usdc: quote.total_usdc!,
  approved: true,
});

const order = await agent.physicalMail.send({
  ...approval,
  idempotencyKey: savedKey, // saved before this call
  maxCost: Number(quote.total_usdc),
});

// If the response was lost:
const recovered = await agent.physicalMail.recover(savedKey);
```

The Python client has the same methods on `client.physical_mail`: `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`, each with an `a`-prefixed async twin.

## Delivery states

`fulfillment_status` moves forward through `accepted`, `mailed`, `in_transit`, `in_local_area`, `processed_for_delivery`, and `delivered`. It can also be `returned_to_sender`, `canceled`, or `render_failed`. A late event never moves the state backwards.

`processed_for_delivery` is not `delivered`.

## Refunds

A confirmed cancellation, or a failure before the piece is produced, refunds the full amount including the service fee as credits, once. Returned mail is not refunded.

## Limits

* US addresses only.
* First-class letters on US letter paper, and 4x6 postcards.
* A letter is a PDF of 1 to 60 pages.
* 20 MB per uploaded file.
* No HTML templates, international mail, checks, or custom envelopes.

## Errors

| Code | When |
| - | - |
| `400 invalid_input` | A field is missing or malformed. `details` says which |
| `400 invalid_artwork`, `400 letter_requires_pdf` | The artwork is not an accepted file |
| `400 max_cost_exceeded` | The quote is above `X-Max-Cost-USDC` |
| `401 invalid_agent_proof` | The signed proof is missing or wrong |
| `402 payment_required` | No credits; pay by x402 |
| `403 action_denied_by_policy` | An action policy refused the send |
| `404 artwork_not_found`, `404 quote_not_found`, `404 order_not_found` | The ID does not exist for this agent |
| `409 quote_changed_or_expired` | The quote expired or no longer matches what was approved |
| `409 approval_required_or_expired` | The quote has no current approval |
| `409 quote_already_ordered` | The quote already bought an order |
| `422 idempotency_key_reuse` | The key was used before with a different body |
| `422 address_not_deliverable` | An address failed the check |
| `422 invalid_letter_pdf`, `422 unsupported_letter_length` | The PDF cannot be read or is not 1 to 60 pages |
| `422 proof_render_failed` | The proof could not be rendered |
| `503 physical_mail_not_configured` | Physical Mail is not set up in this environment |
| `503 mail_rate_unavailable` | A price could not be worked out; retry |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.