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

# Aro Mail

> Turn a confidential cAROUSD transfer into a physical letter: value is escrowed, printed as a QR code plus claim code, mailed through Lob, and claimed by the recipient into their own wallet.

## The idea in one paragraph

**aro-mail** turns a private cAROUSD transfer into a physical letter. The sender
creates a letter, the value moves into escrow, and AroPay renders a one-page PDF
carrying a QR code and a 28-character claim code. The sender prints it, or has
AroPay print and mail it through **Lob**. The recipient scans the QR (or opens
the link), signs in to AroPay, picks a wallet, and claims: the escrow releases
cAROUSD into their wallet. From there they can hold it, transfer it privately,
or [redeem](/guides/redeem-carousd) it for stablecoin. Unclaimed letters expire
and refund the sender automatically; the sender can void an unclaimed letter at
any time. AroPay delivers the digital dollars; local fiat conversion is the
recipient's responsibility.

## Why it fits AroPay

* Extends [confidential transfers](/concepts/confidential-transfers) into an
  offline delivery channel: all three money legs (escrow, release, refund) are
  cAROUSD `confidentialTransfer`s with FHE-encrypted amounts.
* Reuses the sandbox's custodial wallets, transaction ledger, receipts, and
  dashboard; every letter leg is a normal
  [transaction](/concepts/transactions) row.
* Reaches recipients who have no wallet or no reliable internet at send time.
* Enterprise use cases: remittances, gifts, disaster relief, legal payments,
  unbanked payouts.

## Scope boundary

<Columns cols={2}>
  <Card title="AroPay owns" icon="circle-check" iconType="duotone">
    Escrow, PDF rendering, mailing through Lob, the claim page, delivery of
    cAROUSD to the recipient's wallet, refunds on void or expiry, and redeem to
    stablecoin.
  </Card>

  <Card title="AroPay does not own" icon="circle-xmark" iconType="duotone">
    Local fiat conversion (NGN, INR, PHP, etc.), bank payouts, off-ramp
    partnerships, or postal transit once Lob hands the letter to the carrier.
    The recipient handles cash-out.
  </Card>
</Columns>

## aro-mail as a 7-layer stack

The OSI model is a useful frame because aro-mail is a protocol: it takes value
at one end, encodes it through several independent layers (crypto, escrow,
print, postage, scan, claim), and reconstructs it at the other end. Each layer
has one job and a clean handoff to the next. If a layer fails (letter lost, QR
damaged, wallet not set up), only that layer needs recovery, never the whole
system.

```mermaid theme={"system"}
flowchart TB
  L7["L7 Application · Sender and recipient intent (send 50, claim 50)"]
  L6["L6 Presentation · Letter rendering (PDF, QR, 28-character claim code, sealed amount)"]
  L5["L5 Session · Claim session (one-time code, guarded status flips, expiry sweep)"]
  L4["L4 Transport · Escrow vault (operator custody, Postgres ledger, verified confidential transfers)"]
  L3["L3 Network · Postal routing (address rules, country allow/block list)"]
  L2["L2 Data link · Envelope and postage (Lob, USPS First Class, certified or registered)"]
  L1["L1 Physical · Paper letter moving through the mail system"]
  L7 --> L6 --> L5 --> L4 --> L3 --> L2 --> L1
```

<AccordionGroup>
  <Accordion title="L1 · Physical: the paper letter" icon="envelope-open">
    The actual sheet of paper carrying ink. AroPay owns the layout template
    (single US Letter page, black on cream, readable in black and white); Lob
    and the postal carrier own the transit.

    **Responsibilities:** print quality, QR contrast, one-time-claim warning.
    **Failure mode:** letter lost, damaged, or destroyed in transit.
    **Recovery:** the sender voids the letter (`POST /transfer/letter/{id}/void`)
    and gets the escrow back, or waits for the expiry sweep to refund it.
  </Accordion>

  <Accordion title="L2 · Data link: envelope, postage, provider handoff" icon="stamp">
    The framing layer that gets a letter from AroPay onto the postal network.
    `POST /transfer/letter/{id}/mail` hands the PDF to **Lob** with a
    `deliveryMethod` of `standard` (USPS First Class) or `tracked` (First Class
    plus USPS Certified for US destinations, Registered for international).
    Lob prices and applies postage; there are no Forever stamps. Test-mode Lob
    keys (`test_…`) validate everything but never print or mail, and the letter
    reports `mail.testMode: true`.

    **Responsibilities:** provider API, idempotent submission, tracking numbers
    and events, expected delivery date. **Failure mode:**
    `402 postage_unavailable` (provider outage or non-address rejection),
    `503 mail_provider_not_configured`. **Recovery:** the letter stays
    `ESCROWED` with the attempt recorded; retry `/mail`, or print it yourself.
  </Accordion>

  <Accordion title="L3 · Network: addressing and routing" icon="signs-post">
    Global address resolution. One flexible schema for every country: ISO
    3166-1 country codes, free-text or ISO 3166-2 regions, optional `postal`,
    first-class `landmark` ("Near …"), with stricter rules for US addresses
    (two-letter state, ZIP or ZIP+4, 64-character lines). Destinations are
    every ISO country minus the deployment's block list (or only its allow
    list, when one is set).

    **Responsibilities:** address validation, country-aware rules, the
    recipient screening hook. **Failure mode:** `400 invalid_postal_address`,
    `400 country_not_supported`, `403 recipient_blocked` (reserved for a future
    sanctions provider). **Recovery:** the sender fixes the address and
    creates a new letter; nothing was escrowed yet.
  </Accordion>

  <Accordion title="L4 · Transport: escrow of encrypted value" icon="lock">
    The reliable-delivery layer. `POST /transfer/letter` moves cAROUSD from the
    sender's custodial wallet into the **escrow vault**: the platform operator
    wallet, with the ledger of who owns what kept in Postgres. Every leg is
    recognised only after its `ConfidentialTransfer` event is decrypted and
    the amount verified, so value cannot be lost or duplicated regardless of
    what happens at L1–L3.

    **Responsibilities:** debit the sender, hold the value, honour void and
    expiry, release to the claimant. **Failure mode:**
    `400 insufficient_confidential_balance`, `502 transfer_failed`,
    `503 escrow_vault_underfunded`. **Recovery:** the transaction row carries
    the error; a failed escrow leaves the letter `FAILED` with nothing moved,
    and a failed release puts the letter back to claimable.
  </Accordion>

  <Accordion title="L5 · Session: claim lifecycle" icon="ticket">
    The one-time session that binds a physical letter to a single successful
    claim. A 128-bit secret is Crockford-base32 encoded with two check symbols
    into a 28-character code, printed as
    `XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` and embedded in the QR as a URL
    fragment. Status machine below; the claim flip and every other transition
    is a guarded conditional update, so two scans cannot both win.

    **Responsibilities:** atomic status flips, single-use enforcement, expiry
    sweep, void authorisation, sender self-claim rejection. **Failure mode:**
    `409 already_claimed`, `410 letter_expired`, `409 letter_not_claimable`,
    `400 cannot_claim_own_letter`. **Recovery:** expired letters refund the
    sender; claimed letters are terminal.
  </Accordion>

  <Accordion title="L6 · Presentation: how the letter looks" icon="file-pdf">
    Rendering of the escrowed transfer into something a human can read and a
    phone camera can decode: PDF layout, QR encoding, sealed vs. visible
    amount, sender and recipient blocks, expiry, and a large stamp overlay
    (`CLAIMED`, `VOID`, `EXPIRED`) once a letter is finished so a reprint can
    never pass as live. Letters are English-only in the MVP.

    **Responsibilities:** `GET /transfer/letter/{id}/pdf`,
    `amountVisibility: sealed | visible`, overlay for terminal states.
    **Failure mode:** `409 letter_not_printable` (DRAFT or FAILED letters),
    unreadable QR. **Recovery:** re-download the PDF from the same letter id;
    the escrowed value at L4 does not change, and the printed code still works
    if it is typed in by hand.
  </Accordion>

  <Accordion title="L7 · Application: sender and recipient intent" icon="user">
    What a human is actually trying to do. Sender: "pay Jane 50 privately, she
    has no wallet yet." Recipient: "claim the letter I received."

    **Responsibilities:** the **Aro Mail** pages in the dashboard for creating,
    printing, mailing, tracking and voiding letters; the public `/claim` page
    on the AroPay host for recipients; optional [redeem](/guides/redeem-carousd)
    after claim. **Failure mode:** the recipient scans but has no AroPay
    account. **Recovery:** accounts are provisioned by Aro Media; the code
    stays claimable until `expiresAt`, so the recipient can come back once
    their account exists. There is no claim reservation.
  </Accordion>
</AccordionGroup>

### Layer boundary at claim

When the recipient scans, the layers unwind in reverse: L1 delivers paper, L6
decodes the QR, L5 validates the code and flips the letter to `CLAIMING`, L4
releases escrow into the recipient's wallet and verifies the amount. L7 then
decides what to do next: hold, transfer, or redeem.

## End-to-end flow

```mermaid theme={"system"}
flowchart TD
  A["Sender<br/>custodial wallet"] -->|"POST /transfer/letter<br/>amount + recipient"| B["Escrow vault<br/>operator wallet, encrypted cAROUSD"]
  B --> C["Letter artifact<br/>PDF + QR + 28-character claim code"]
  C -->|"self-print<br/>GET /transfer/letter/:id/pdf"| D["Postal system"]
  C -->|"POST /transfer/letter/:id/mail<br/>Lob"| D
  D --> E["Recipient<br/>opens envelope"]
  E -->|"scan QR or open link"| F["AroPay claim page<br/>/claim#CODE"]
  F -->|"POST /transfer/letter/claim"| G["Recipient wallet<br/>cAROUSD credited"]
  G -->|"optional POST /redeem"| H["Stablecoin<br/>in recipient wallet"]
  B -. "void or expiry sweep: refund" .-> A
```

## Status machine

```mermaid theme={"system"}
stateDiagram-v2
  direction LR
  [*] --> DRAFT : POST /transfer/letter
  DRAFT --> ESCROWED : escrow confirmed and amount verified
  DRAFT --> FAILED : escrow reverted, clamped, or never submitted
  ESCROWED --> MAILED : POST /mail accepted by Lob
  ESCROWED --> CLAIMING : POST /claim
  MAILED --> CLAIMING : POST /claim
  CLAIMING --> CLAIMED : release confirmed and amount verified
  CLAIMING --> ESCROWED : release reverted or clamped
  CLAIMING --> MAILED : release reverted or clamped (letter was mailed)
  ESCROWED --> VOIDED : POST /void
  MAILED --> VOIDED : POST /void with acknowledgeMailed
  ESCROWED --> EXPIRED : expiresAt passed
  MAILED --> EXPIRED : expiresAt passed
  CLAIMED --> [*]
  VOIDED --> [*]
  EXPIRED --> [*]
  FAILED --> [*]
```

| Status     | Meaning                                                                                                  |
| ---------- | -------------------------------------------------------------------------------------------------------- |
| `DRAFT`    | Row created and the escrow transfer submitted (or about to be). Not printable, not claimable.            |
| `ESCROWED` | Escrow confirmed **and** the transferred amount verified. Printable, mailable, claimable, voidable.      |
| `MAILED`   | Lob accepted the letter. Claimable; voidable only with `acknowledgeMailed: true` (stop-payment).         |
| `CLAIMING` | A recipient won the claim flip and the release transfer is in flight. Never a source for void or expiry. |
| `CLAIMED`  | Release confirmed and verified. Terminal.                                                                |
| `EXPIRED`  | `expiresAt` passed while claimable; a refund leg returns the value to the sender. Terminal.              |
| `VOIDED`   | The sender cancelled; a refund leg returns the value. Terminal.                                          |
| `FAILED`   | **No value left the sender**: the escrow reverted, clamped to zero, or was never submitted. Terminal.    |

`CLAIMING` and `FAILED` are implementation states beyond the original
`DRAFT → ESCROWED → MAILED → CLAIMED | EXPIRED | VOIDED` sketch. Claimable
statuses are `ESCROWED` and `MAILED`; terminal statuses are `CLAIMED`,
`EXPIRED`, `VOIDED`, `FAILED`.

Rules worth knowing:

* **Every transition is guarded.** Status changes are conditional updates
  keyed on the current status (and `expiresAt` for claims), so a second
  claimant, a concurrent void, and the expiry sweep cannot race each other into
  double-spending the escrow.
* **Confirmed is not paid.** ERC-7984 `confidentialTransfer` clamps an
  over-spend to an encrypted zero instead of reverting, so AroPay decrypts the
  `ConfidentialTransfer` event of every leg and compares it with the letter
  amount before moving the status. A clamped escrow becomes `FAILED`
  (`400 insufficient_confidential_balance`); a clamped release rolls the letter
  back to claimable (`503 escrow_vault_underfunded`).
* **Refunds are tracked separately from status.** `refund` on the letter
  points at the `LETTER_REFUND` transaction; a failed refund never un-expires
  or un-voids the letter and is retried by the sweep.
* **Senders cannot claim their own letter** (`400 cannot_claim_own_letter`),
  by account or by wallet address. Recovery is `/void`, so the audit trail
  always shows a void.
* **Reads never move money.** Listing letters or previewing a claim
  reconciles receipts and derives status, but refunds, releases and Lob calls
  only happen on owner detail reads, on the write endpoints, and in the sweep.

## Escrow model and trust model

There is no new smart contract. The escrow vault is the platform **operator
wallet** holding cAROUSD, and the ledger of which letter owns how much lives in
Postgres. Three on-chain legs move value, each recorded as a
[transaction](/concepts/transactions) row you can list, poll and receipt:

| Leg     | Transaction type | From → to                          | When                          |
| ------- | ---------------- | ---------------------------------- | ----------------------------- |
| Escrow  | `LETTER_ESCROW`  | Sender custodial wallet → operator | `POST /transfer/letter`       |
| Release | `LETTER_CLAIM`   | Operator → recipient wallet        | `POST /transfer/letter/claim` |
| Refund  | `LETTER_REFUND`  | Operator → sender address          | Void, or expiry sweep         |

<Note>
  On-chain, the amounts of all three legs are FHE-encrypted (`confidentialTransfer`).
  Off-chain, the AroPay ledger holds the cleartext letter amount, exactly as it
  already does for every custodial-wallet transaction. The API therefore returns
  `amount` as a plain decimal string, and observers of the chain still see only
  that the operator sent or received *something*.
</Note>

The operator's cAROUSD balance is shared between letters in flight and other
operator duties. The expiry sweep reports vault solvency
(`vault.balance`, `vault.inFlight`, `vault.solvent`) so operators can top the
vault up before a release would clamp.

## Claim code and claim URL

* **Secret.** 16 random bytes (128 bits) encoded in Crockford base32
  (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`, no `I`, `L`, `O`, `U`) plus two check
  symbols derived from the secret's SHA-256, for **28 symbols** displayed in
  groups of four: `XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX`.
* **Forgiving input.** Codes are upper-cased, dashes and whitespace are
  stripped, and `O→0`, `I→1`, `L→1` are corrected before the checksum is
  verified. Anything else is `400 invalid_claim_code`.
* **Storage.** Only a SHA-256 hash is used for lookup; the code itself is
  encrypted at rest and returned to the **owner** in the create and detail
  responses while the letter is non-terminal. It never appears in list items,
  in the public preview, in URL paths or query strings, or in logs.
* **Claim URL.** `https://<aropay-host>/claim#<code>`: the code travels in the
  URL fragment, which browsers never send to the server. The claim page reads
  it, removes it from the address bar immediately, keeps it in session storage
  across sign-in, and never puts it in a redirect target. The QR encodes this
  URL. The origin is fixed when the letter is created (from the deployment's
  `AROPAY_PUBLIC_URL`), so a reprint months later still points at the same
  place, and mailing requires an `https:` origin
  (`400 claim_origin_not_public`).
* **Manual entry.** The letter also prints the code under *OR ENTER CODE* for
  recipients whose camera cannot read the QR.

## What the letter looks like

Rendered mockup of a sandbox letter. Real letters use the recipient's postal
address; the QR encodes the full claim URL, with the code in the fragment.

<Frame>
  <div
    style={{
fontFamily: "ui-sans-serif, system-ui, sans-serif",
background: "#faf7f2",
color: "#1a1a1a",
border: "1px solid #d9d3c7",
borderRadius: "8px",
padding: "32px",
maxWidth: "640px",
margin: "0 auto",
boxShadow: "0 1px 2px rgba(0,0,0,0.04)"
}}
  >
    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", borderBottom: "1px solid #d9d3c7", paddingBottom: "16px", marginBottom: "20px" }}>
      <div>
        <div style={{ fontSize: "11px", letterSpacing: "0.15em", color: "#8a7f6c", textTransform: "uppercase" }}>AroPay · aro-mail</div>
        <div style={{ fontSize: "20px", fontWeight: 600, marginTop: "4px" }}>Confidential Letter of Value</div>
      </div>

      <div style={{ textAlign: "right", fontSize: "11px", color: "#8a7f6c" }}>
        <div>LETTER ID</div>
        <div style={{ color: "#1a1a1a", fontFamily: "ui-monospace, monospace" }}>cklet0001abcd</div>
      </div>
    </div>

    <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px", marginBottom: "24px" }}>
      <div>
        <div style={{ fontSize: "10px", letterSpacing: "0.12em", color: "#8a7f6c", textTransform: "uppercase", marginBottom: "4px" }}>From</div>

        <div style={{ fontSize: "13px", lineHeight: 1.4 }}>
          Acme Payroll, Inc.<br />
          Wallet 0x8Ba1…BA72
        </div>
      </div>

      <div>
        <div style={{ fontSize: "10px", letterSpacing: "0.12em", color: "#8a7f6c", textTransform: "uppercase", marginBottom: "4px" }}>To</div>

        <div style={{ fontSize: "13px", lineHeight: 1.4 }}>
          Jane Doe<br />
          12 Adeola Odeku Street<br />
          Victoria Island<br />
          Near Eko Hotel<br />
          Lagos LA<br />
          Nigeria
        </div>
      </div>
    </div>

    <div style={{ background: "#fff", border: "1px dashed #c9c0ac", borderRadius: "6px", padding: "20px", textAlign: "center", marginBottom: "24px" }}>
      <div style={{ fontSize: "10px", letterSpacing: "0.12em", color: "#8a7f6c", textTransform: "uppercase", marginBottom: "8px" }}>Sealed amount</div>
      <div style={{ fontSize: "28px", fontWeight: 700, letterSpacing: "0.02em" }}>••••••• cAROUSD</div>
      <div style={{ fontSize: "11px", color: "#8a7f6c", marginTop: "6px" }}>Revealed to recipient on claim</div>
    </div>

    <div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "20px", alignItems: "center", marginBottom: "20px" }}>
      <div
        style={{
width: "120px",
height: "120px",
background: "repeating-conic-gradient(#1a1a1a 0% 25%, #faf7f2 0% 50%) 50% / 12px 12px",
border: "6px solid #faf7f2",
boxShadow: "0 0 0 1px #1a1a1a",
borderRadius: "4px"
}}
      />

      <div>
        <div style={{ fontSize: "10px", letterSpacing: "0.12em", color: "#8a7f6c", textTransform: "uppercase", marginBottom: "4px" }}>Claim at</div>
        <div style={{ fontSize: "12px", fontFamily: "ui-monospace, monospace", wordBreak: "break-all" }}>dev.aropay.aro.media/claim#7K3M-Q9TX-2ABF-H8RD-5CWN-VGP4-0J6Y</div>
        <div style={{ fontSize: "10px", letterSpacing: "0.12em", color: "#8a7f6c", textTransform: "uppercase", margin: "10px 0 4px" }}>Or enter code</div>
        <div style={{ fontSize: "15px", fontWeight: 600, fontFamily: "ui-monospace, monospace", letterSpacing: "0.08em", wordBreak: "break-all" }}>7K3M-Q9TX-2ABF-H8RD-5CWN-VGP4-0J6Y</div>
      </div>
    </div>

    <div style={{ fontSize: "11px", color: "#8a7f6c", borderTop: "1px solid #d9d3c7", paddingTop: "12px", display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: "8px" }}>
      <span>Expires 2026-10-22</span>
      <span>One-time claim · Do not photograph</span>
    </div>

    <div style={{ fontSize: "11px", color: "#8a7f6c", marginTop: "8px" }}>
      Scan the code or open the link, sign in to AroPay, choose a wallet, claim.
    </div>
  </div>
</Frame>

Sealed letters print `••••••• cAROUSD`; visible letters print the amount and
`1 cAROUSD = 1 USD`. Finished letters (claimed, voided, expired) re-render with
a large diagonal stamp across the page.

## The API

All endpoints live under `/transfer/letter` and use the shared envelope. Full
schemas and a playground are in the
[API reference](/api-reference/introduction); the step-by-step is in
[Send Aro Mail](/guides/send-aro-mail).

| Method | Path                             | Auth                   | Purpose                                                                                                           |
| ------ | -------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `POST` | `/transfer/letter`               | API key or session     | Create a letter and escrow the value. Returns `201 { letter }` with `claimCode`, `claimUrl` and `pdfUrl`.         |
| `GET`  | `/transfer/letter`               | API key or session     | Your letters, newest first (`items`, `page`, `pageSize`, `total`; filter by `status`). Items omit the claim code. |
| `GET`  | `/transfer/letter/{id}`          | API key or session     | One letter, reconciled, with the Lob status refreshed for mailed letters.                                         |
| `GET`  | `/transfer/letter/{id}/pdf`      | API key or session     | The printable PDF (`application/pdf`).                                                                            |
| `POST` | `/transfer/letter/{id}/mail`     | API key or session     | Print and mail through Lob: `{ "deliveryMethod": "standard" }` or `"tracked"`.                                    |
| `POST` | `/transfer/letter/{id}/void`     | API key or session     | Cancel and refund: `{ "acknowledgeMailed": true }` required once mailed.                                          |
| `POST` | `/transfer/letter/claim/preview` | Public, per-IP limit   | `{ "claimCode" }` → sender name, state, expiry, and the amount for visible letters.                               |
| `POST` | `/transfer/letter/claim`         | API key or session     | `{ "claimCode", "walletId" }` → `{ letter, transaction }`; releases escrow into the wallet.                       |
| `POST` | `/transfer/letter/sweep`         | Operator bearer secret | Expiry and refund sweep for schedulers.                                                                           |

### Create a letter

```http theme={"system"}
POST /transfer/letter
```

```json theme={"system"}
{
  "walletId": "ckwlt0001abcd",
  "amount": "50",
  "recipient": {
    "name": "Jane Doe",
    "address": {
      "line1": "12 Adeola Odeku Street",
      "line2": "Victoria Island",
      "landmark": "Opposite Eko Hotel",
      "city": "Lagos",
      "region": "LA",
      "country": "NG"
    }
  },
  "memo": "Thanks for the referral",
  "amountVisibility": "sealed",
  "expiresInDays": 45
}
```

```json theme={"system"}
{
  "ok": true,
  "data": {
    "letter": {
      "id": "cklet0001abcd",
      "status": "ESCROWED",
      "chainId": 11155111,
      "amount": "50.00",
      "token": "cAROUSD",
      "amountVisibility": "sealed",
      "memo": "Thanks for the referral",
      "recipient": {
        "name": "Jane Doe",
        "address": {
          "line1": "12 Adeola Odeku Street",
          "line2": "Victoria Island",
          "landmark": "Opposite Eko Hotel",
          "city": "Lagos",
          "region": "LA",
          "postal": null,
          "country": "NG",
          "countryName": "Nigeria"
        }
      },
      "isDomestic": false,
      "expiresAt": "2026-10-22T09:45:00.000Z",
      "sender": { "walletId": "ckwlt0001abcd", "address": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72" },
      "claimCode": "7K3M-Q9TX-2ABF-H8RD-5CWN-VGP4-0J6Y",
      "claimUrl": "https://dev.aropay.aro.media/claim#7K3M-Q9TX-2ABF-H8RD-5CWN-VGP4-0J6Y",
      "pdfUrl": "https://dev.aropay.aro.media/api/v1/transfer/letter/cklet0001abcd/pdf",
      "escrow": {
        "transactionId": "cktxn0005abcd",
        "txHash": "0x4d1e…",
        "explorerUrl": "https://sepolia.etherscan.io/tx/0x4d1e…",
        "status": "CONFIRMED",
        "verified": true
      },
      "claim": null,
      "refund": null,
      "mail": null,
      "needsReview": false,
      "error": null,
      "createdAt": "2026-09-07T09:45:00.000Z",
      "updatedAt": "2026-09-07T09:45:31.000Z"
    }
  }
}
```

The call blocks up to \~20 s for the escrow receipt. If it returns with
`status: "DRAFT"` and `escrow.status: "PENDING"`, poll `GET /transfer/letter/{id}`;
the letter becomes `ESCROWED` once the receipt lands and the amount verifies.

### Print and mail on the sender's behalf

```http theme={"system"}
POST /transfer/letter/{id}/mail
```

```json theme={"system"}
{ "deliveryMethod": "tracked", "provider": "lob" }
```

Returns the letter as `MAILED` with `mail.providerId`, `mail.status`,
`mail.trackingNumber`, `mail.expectedDeliveryDate` and `mail.testMode`.

### Preview and claim

```http theme={"system"}
POST /transfer/letter/claim/preview
```

```json theme={"system"}
{ "claimCode": "7K3M-Q9TX-2ABF-H8RD-5CWN-VGP4-0J6Y" }
```

```json theme={"system"}
{
  "ok": true,
  "data": {
    "preview": {
      "state": "claimable",
      "amount": null,
      "token": "cAROUSD",
      "amountVisibility": "sealed",
      "senderName": "Acme Payroll, Inc.",
      "memo": null,
      "expiresAt": "2026-10-22T09:45:00.000Z",
      "claimedAt": null,
      "chainId": 11155111
    }
  }
}
```

```http theme={"system"}
POST /transfer/letter/claim
```

```json theme={"system"}
{ "claimCode": "7K3M-Q9TX-2ABF-H8RD-5CWN-VGP4-0J6Y", "walletId": "ckwlt0004abcd" }
```

Moves cAROUSD from the escrow vault into the claiming wallet and returns
`{ letter, transaction }`. Idempotent for the same claimant: calling again
returns the existing claim. A sealed letter's `amount` and `memo` are revealed
here, never in the public preview.

### Void

```http theme={"system"}
POST /transfer/letter/{id}/void
```

```json theme={"system"}
{ "acknowledgeMailed": true }
```

Refunds the sender while the letter is `ESCROWED`, or `MAILED` when the body
carries `acknowledgeMailed: true` (the paper letter will then fail with
`409 letter_not_claimable`; AroPay also asks Lob to cancel the mailing, best
effort).

## Addresses, destinations and limits

| Rule           | Detail                                                                                                                                                                                                     |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Recipient name | 1–40 characters (Lob's limit).                                                                                                                                                                             |
| Address fields | `line1` (required), `line2`, `landmark`, `city` (required), `region`, `postal`, `country` (ISO 3166-1 alpha-2, required).                                                                                  |
| US addresses   | `region` must be a two-letter state or territory code, `postal` must be a ZIP or ZIP+4, `line1`/`line2` at most 64 characters.                                                                             |
| International  | `region` or `postal` must be present; `region` is free text (an ISO 3166-2 code or the local name).                                                                                                        |
| Landmark       | Printed as its own `Near …` line; folded into the second address line for Lob, so `line2 + landmark` must fit the country's limit (64 US / 200 international).                                             |
| Destinations   | Every ISO country, minus the deployment's block list (`country_not_supported`); an allow list, when set, restricts further. Both lists are operator-managed and published by `GET /transfer/letter/rules`. |
| Per-letter cap | Operator-configured (default 10,000 cAROUSD; `maxAmount` in `GET /transfer/letter/rules`) → `400 invalid_amount` above it.                                                                                 |
| Velocity       | In-flight letters per sender (default 25) and per destination country (default 250) → `429 letter_velocity_limit`.                                                                                         |

## Defaults

| Setting            | Domestic (destination = home country, default `US`) | International                 |
| ------------------ | --------------------------------------------------- | ----------------------------- |
| `amountVisibility` | `visible`                                           | `sealed`                      |
| `expiresInDays`    | 90                                                  | 45                            |
| `tracked` delivery | USPS First Class + Certified                        | USPS First Class + Registered |

Both defaults can be overridden per letter (`amountVisibility`, and
`expiresInDays` from 1 to 180).

## Error codes

| Status | Code                                | When                                                                                                                                                                |
| ------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_postal_address`            | The address fails the rules above, or Lob rejects it.                                                                                                               |
| 400    | `country_not_supported`             | Blocked or not allow-listed destination.                                                                                                                            |
| 400    | `invalid_claim_code`                | Malformed code or checksum failure.                                                                                                                                 |
| 400    | `letter_not_escrowed`               | `/mail` or `/void` on a letter that is not in an allowed state.                                                                                                     |
| 400    | `void_requires_acknowledgement`     | Void of a `MAILED` letter without `acknowledgeMailed: true`.                                                                                                        |
| 400    | `letter_already_mailed`             | `/mail` called twice.                                                                                                                                               |
| 400    | `delivery_method_unavailable`       | `courier` is not offered.                                                                                                                                           |
| 400    | `cannot_claim_own_letter`           | The sender (or the sender's address) tried to claim.                                                                                                                |
| 400    | `claim_origin_not_public`           | Mailing requested but the letter's claim origin is not `https:`.                                                                                                    |
| 400    | `insufficient_confidential_balance` | The sender cannot fund the escrow (or the escrow clamped to zero).                                                                                                  |
| 402    | `postage_unavailable`               | Lob outage or a non-address rejection.                                                                                                                              |
| 403    | `recipient_blocked`                 | Reserved for a future sanctions/PEP screening provider.                                                                                                             |
| 404    | `letter_not_found`                  | Unknown letter id, or unknown claim code.                                                                                                                           |
| 409    | `already_claimed`                   | Another principal or wallet already claimed.                                                                                                                        |
| 409    | `letter_not_claimable`              | Claim on a `DRAFT`, `VOIDED` or `FAILED` letter.                                                                                                                    |
| 409    | `letter_not_printable`              | PDF for a `DRAFT` or `FAILED` letter.                                                                                                                               |
| 410    | `letter_expired`                    | Claim after `expiresAt`.                                                                                                                                            |
| 429    | `letter_velocity_limit`             | Per-sender or per-country in-flight cap.                                                                                                                            |
| 502    | `transfer_failed` / `claim_failed`  | Escrow or release transfer reverted on-chain.                                                                                                                       |
| 502    | `escrow_unsettled`                  | Escrow transfer broadcast but bookkeeping failed; the letter stays `DRAFT` and is finished by reconciliation on its next read. Do not create it again.              |
| 502    | `claim_unsettled`                   | Release transfer broadcast but bookkeeping failed; the letter stays `CLAIMING` and reconciliation completes it. The same claimant retrying gets the existing claim. |
| 503    | `mail_provider_not_configured`      | Lob key missing or return address incomplete (platform-side).                                                                                                       |
| 503    | `public_url_not_configured`         | `AROPAY_PUBLIC_URL` unset in production (platform-side).                                                                                                            |
| 503    | `escrow_vault_underfunded`          | The operator's cAROUSD balance is below the payout.                                                                                                                 |

The full catalog, including the generic codes, is in
[Errors](/api-reference/errors).

## Decisions made for the MVP

1. **Escrow is the operator vault plus a Postgres ledger; there is no escrow
   contract.** Every leg is a verified cAROUSD `confidentialTransfer`; the
   ledger row (`SandboxLetter`) is the source of truth for who owns what, and
   every status change is a guarded conditional update.
2. **Lob is the only fulfilment provider.** PostGrid, Letter Labs and courier
   delivery are not offered; the provider interface exists so they can be added,
   and `deliveryMethod: "courier"` returns `400 delivery_method_unavailable`.
3. **Postage is priced and applied by Lob.** There are no Forever or Global
   Forever stamps; `standard` is USPS First Class, `tracked` adds Certified
   (US) or Registered (international).
4. **The claim code is 28 characters**, Crockford base32 with two check
   symbols, printed as seven groups of four. The eight-character `6H2K-QT4P`
   sketch is gone.
5. **The claim URL is `https://<aropay-host>/claim#<code>`** on the AroPay
   host itself, with the code in the URL fragment. There is no
   `claim.aropay.aro.media/{code}` subdomain and the code never travels in a
   path or query string.
6. **`amount_visibility_invalid` and the `format` field are gone.** An invalid
   `amountVisibility` is an ordinary `400 validation_error`; the PDF is the
   only format and is fetched from `GET /transfer/letter/{id}/pdf`.
7. **Screening is a country allow/block hook only.** No sanctions/PEP
   provider is wired in; `403 recipient_blocked` is reserved for one.
8. **No claim reservation.** A code stays claimable until it is claimed,
   voided or expired; a recipient who scans before they have an account can
   come back later.
9. **Letters are English-only.** No label localisation in the MVP.
10. **Nothing is printed on the envelope** beyond the address Lob applies;
    the amount is never visible from outside, regardless of
    `amountVisibility`.
11. **Void is allowed after mailing**, with an explicit
    `acknowledgeMailed: true` (stop-payment semantics); the paper letter then
    fails with `409 letter_not_claimable`.
12. **Senders cannot claim their own letter**, by account or by wallet
    address (`400 cannot_claim_own_letter`); they void instead.
13. **No self-service sign-up.** Recipients need an AroPay account provisioned
    by Aro Media; the claim page explains this and points to
    `support@aro.media`.

## Availability

|                      |                                                                                                                                                 |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **Destinations**     | Every ISO 3166-1 country, minus the deployment's block list (an allow list, when set, restricts further).                                       |
| **Delivery methods** | `standard` (USPS First Class); `tracked` (First Class + Certified for US destinations, + Registered internationally). `courier` is not offered. |
| **Transit time**     | Per Lob and the destination postal service; `mail.expectedDeliveryDate` carries Lob's estimate, and `tracked` letters expose tracking events.   |
| **Test mode**        | A Lob `test_…` key validates addresses and returns dummy tracking but **never prints or mails**; letters report `mail.testMode: true`.          |
| **Letter format**    | Single US Letter page, English, black on cream; readable in black and white.                                                                    |
| **Expiry**           | 90 days domestic / 45 days international by default; 1–180 days per letter.                                                                     |

## Not in the MVP

Lob webhooks (status is refreshed on owner reads instead), PostGrid or Letter
Labs implementations, a real sanctions/PEP provider, claim reservations,
self-service account sign-up, envelope printing options, per-country
availability beyond allow/block lists, and localisation.

## Related

* [Send Aro Mail](/guides/send-aro-mail): the step-by-step guide.
* [Confidential transfers](/concepts/confidential-transfers): how the
  escrow, release and refund legs stay private.
* [Transactions](/concepts/transactions): the `LETTER_*` rows every leg
  writes.
