Agents
PostalForm Agents Guide
PostalForm lets agents place a real print-and-mail order on behalf of their owner. Give your agent the link to this page to let it figure out how to perform a real mailing on your behalf.
Published Feb 15, 2026 • Updated May 31, 2026
Pick A Path
MPP + Stripe SPT
Use this for autonomous card or Link payments. Parse the Stripe challenge, mint anspt_...token, then replay the same JSON body with a serialized MPP credential.
MPP + Tempo
Use this if your runtime already speaks MPP crypto flows and can settle the Tempo challenge directly.
Need a simpler fallback?
Use legacy x402 if you want direct autonomous payment without Stripe SPT provisioning, or use hosted checkout when a human should review and pay.
Bulk campaigns use the same MPP endpoints. Only the payload changes: addbulkand keep it identical on retry.
Remote MCP connector details
Use these details when adding PostalForm to an MCP client, connector directory, or agent registry:
- Remote MCP endpoint:
https://postalform.com/mcp - Transport: streamable HTTP
- A2A Agent Card:
https://postalform.com/.well-known/agent-card.json - A2A JSON-RPC discovery bridge:
https://postalform.com/a2a - Server card:
https://postalform.com/.well-known/mcp/server-card.json - Compatibility manifest:
https://postalform.com/.well-known/mcp.json - MCP Registry server manifest:
https://postalform.com/.well-known/mcp/server.json - Agent skill:
https://postalform.com/skill.md - LLM overview:
https://postalform.com/llms.txt - Full LLM content dump:
https://postalform.com/llms-full.txt - Developer docs:
https://postalform.com/developers
For chat clients where a person reviews payment, prefer hosted-checkout draft tools. For autonomous runtimes with explicit owner approval and a spend limit, use the direct machine-order tools and payment paths below.
For the product architecture behind this default, see making physical mail callable from AI agents.
Client setup notes
PostalForm works with MCP-capable clients and runtimes that can reach a public streamable HTTP server, including ChatGPT custom connectors or OpenAI Responses API MCP tools, Gemini SDKs, Gemini CLI, Claude, Claude Code, Codex, Cursor, Windsurf, Cline, Replit, OpenClaw, Hermes, n8n, LangChain, and similar clients.
ChatGPT and OpenAI API integrations should configure a remote MCP server with server_url set to https://postalform.com/mcp and require approval before sharing user documents, addresses, or payment-related data. Gemini CLI can add PostalForm with:
gemini mcp add --transport http postalform https://postalform.com/mcp
Gemini SDK integrations can connect an MCP client session to the same endpoint and pass that session into Gemini's MCP/tool-calling integration. In all chat-style clients, use hosted-checkout draft tools unless the user explicitly authorized autonomous machine payment.
Machine payments overview
x402
x402 machine payments use an HTTP 402 handshake:
- Optional (recommended): your agent sends
POST /api/machine/orders/validateto get a quote and validation hints before payment. - Your agent sends
POST /api/machine/orderswith the same order payload (no payment header). - PostalForm responds with
402and aPAYMENT-REQUIREDheader describing how to pay. - Your agent pays and retries the same request with a
PAYMENT-SIGNATUREheader. - PostalForm settles the payment and returns
202with aPAYMENT-RESPONSEheader.
Endpoints
- Validate and quote (no payment side effects):
POST https://postalform.com/api/machine/orders/validate - Create and pay:
POST https://postalform.com/api/machine/orders - Poll status:
GET https://postalform.com/api/machine/orders/:id
Networks and assets
- Live deployments typically settle with USDC on Base.
- Test environments may use testnets (for example Base Sepolia).
MPP endpoint
The MPP machine path also uses an HTTP 402 flow, but with the MPP headers and receipt format:
- Optional (recommended): your agent sends
POST /api/machine/mpp/orders/validateto get a quote and validation hints before payment. - Your agent sends
POST /api/machine/mpp/orderswith the same order payload and no payment credential. - PostalForm responds with
402andWWW-Authenticate: Payment .... - Your agent pays and retries the same request with
Authorization: Payment .... - PostalForm verifies the payment and returns
202with aPayment-Receiptheader while the order waits for Stripe webhook finalization.
Endpoints
- Validate and quote (no payment side effects):
POST https://postalform.com/api/machine/mpp/orders/validate - Create and pay:
POST https://postalform.com/api/machine/mpp/orders - Poll status:
GET https://postalform.com/api/machine/mpp/orders/:id
Discovery
PostalForm exposes machine-payment discovery in two layers:
- Canonical discovery:
GET https://postalform.com/openapi.json - A2A Agent Card:
GET https://postalform.com/.well-known/agent-card.json - x402 manifest:
GET https://postalform.com/.well-known/x402 - x402 manifest alias:
GET https://postalform.com/.well-known/x402.json - Workflow forms catalog:
GET https://postalform.com/api/machine/forms - Workflow form schema:
GET https://postalform.com/api/machine/forms/{slug}/schema - Flower categories:
GET https://postalform.com/api/flowers/categories - Flower products:
GET https://postalform.com/api/flowers/products?category=bs&count=18&start=1&sorttype=pa - Flower delivery dates:
GET https://postalform.com/api/flowers/delivery-dates?zipcode=32503
Use the OpenAPI document by default. It includes per-route input schemas, payable route metadata (x-payment-info), high-level guidance in info.description, and x-agent-guidance for agent planning.
Use the workflow form endpoints when the agent needs PostalForm to fill a known form PDF from structured JSON. The catalog lists published machine-discoverable forms. The schema endpoint returns fields, groups, dependencies, attachment requirements, and machine submission paths. Fill values by field id from that schema, then send a top-level form object to the same validate/create endpoints used for PDF-backed machine orders.
The /.well-known/x402 manifest is a lightweight compatibility layer for x402 crawlers and payment-aware agent runtimes. It advertises the create route, pricing mode, network, token, facilitator URL, and the validate/status endpoints for the machine order flow.
For a shorter x402-specific overview with directory fields, endpoint tables, and safety defaults, see x402 physical mail API.
Payment instruments:
- Tempo crypto for MPP-capable crypto clients. Use Tempo testnet in local/integration environments and Tempo mainnet in live environments once enabled for your account.
- Stripe Shared Payment Tokens (
spt_...) for card/Link-capable agentic clients. Your agent must mint thespt_...token through Stripe; PostalForm does not accept raw card details over this API.
If you want to use credit cards under the MPP flow, we recommend using the Stripe Link CLI to safely issue an SPT token.
For card payments, this Link -> SPT flow is the recommended payment pathway.
Very brief Link CLI setup:
- Install the CLI (see the GitHub install instructions):
brew install stripe/link-cli/link(macOS/Homebrew) ornpm i -g @stripe/link. - Authenticate the CLI with your Stripe account:
link auth login. - Use the CLI to mint an
spt_...Shared Payment Token and pass it in the MPP payment flow.
Today the MPP path is best for agents that already speak MPP challenge/receipt semantics and can pay with Tempo or Stripe Shared Payment Tokens directly. If your agent does not already support MPP or Stripe SPT provisioning, start with x402 (purl) or the MCP draft + hosted checkout flow instead.
Flower letters overview
Flower letters are a dedicated machine-payment product. Do not send them through the PDF, form, or bulk mail endpoints. Use the flower catalog endpoints to choose an arrangement, then use the dedicated flower-letter machine endpoints to quote, pay, and track the order.
Use this flow when an agent should send real flowers with a card note attached:
- Choose the delivery ZIP from the final recipient address.
- List categories with
GET https://postalform.com/api/flowers/categories. - List arrangements with
GET https://postalform.com/api/flowers/products?category=bs&count=18&start=1&sorttype=pa. - Select one product from
products; use itsCODEasproduct_code. Product responses include Florist One fields such asCODE,NAME,PRICE,DESCRIPTION,SMALL, andLARGE. TreatPRICEas the arrangement price before delivery and tax, not the final customer total. - Fetch available dates with
GET https://postalform.com/api/flowers/delivery-dates?zipcode=<recipient_zip>. - Build a flower-letter JSON payload with the selected
product_code, selecteddelivery_date, full sender and recipient details, and anoteof 200 characters or fewer. - Validate and quote with
POST https://postalform.com/api/machine/mpp/flower-letters/validate. The validate response is the first point where PostalForm has the destination ZIP, delivery date, product, and final Florist One total together. - Create the unpaid order with
POST https://postalform.com/api/machine/mpp/flower-lettersand noAuthorizationheader. - Answer the returned
WWW-Authenticate: Payment ...challenge using the same MPP process described below. - Retry the exact same
POST /api/machine/mpp/flower-lettersbody withAuthorization: Payment .... - Read
Payment-Receipt, then pollGET https://postalform.com/api/machine/mpp/flower-letters/:iduntilpayment_statusbecomespaid.
Important flower-letter rules:
request_idis the idempotency key. Reuse it with the exact same JSON body after402; generate a fresh UUID for a different flower delivery.buyer_emailis required for machine payment receipts.customer.emailis optional and defaults tobuyer_email.noteis printed on the florist card and must be 200 characters or fewer.zipcodemust be the recipient delivery ZIP and must matchrecipient.zipcode.customeris the sender.recipientis the person or facility receiving the flowers.- Both
customer.phoneandrecipient.phonemust resolve to 10 US digits. countrymay be omitted; PostalForm defaults it toUS.allow_substitutionsdefaults totrue.- Do not present catalog
PRICEas the amount due. Present it as "before delivery and tax" until the validate response returnsquote.total.orderTotalCents. - PostalForm validates the product, ZIP, selected delivery date, and total before issuing a payment challenge. The payment challenge description and response body include the same final ZIP-based price breakdown when available. PostalForm checks the delivery date again when submitting the paid order to Florist One.
- Machine flower totals are capped by PostalForm's configured flower machine-payment limit. If validation returns
amount_exceeds_limit, choose a lower-priced arrangement.
Flower-letter MPP example
curl -sS https://postalform.com/api/machine/mpp/flower-letters/validate \
-H 'Content-Type: application/json' \
--json '{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"note": "For your desk, your day, and the smile I hope this brings.",
"zipcode": "32503",
"product_code": "F1-509",
"delivery_date": "2026-05-08",
"customer": {
"name": "Agent Owner",
"phone": "8505550100",
"address1": "123 Sender St",
"city": "Pensacola",
"state": "FL",
"zipcode": "32503"
},
"recipient": {
"name": "Recipient Example",
"phone": "8505550199",
"address1": "456 Recipient Ave",
"city": "Pensacola",
"state": "FL",
"zipcode": "32503"
},
"allow_substitutions": true
}'
The validate response includes protocol: "mpp", methods: ["tempo", "stripe_spt"], product_type: "flower_letter", and a quote with the selected product and final total in cents. Use quote.product.priceCents for the arrangement price before delivery and tax. Use quote.total.orderTotalCents as the customer-facing amount due, and show quote.total.deliveryChargeCents and quote.total.taxTotalCents as the breakdown when present.
Then create and pay using the same body:
curl -i https://postalform.com/api/machine/mpp/flower-letters \
-H 'Content-Type: application/json' \
--json @flower-letter-body.json
On 402, parse the WWW-Authenticate: Payment ... headers, choose either Tempo or Stripe SPT, serialize the full MPP credential, and retry:
curl -i https://postalform.com/api/machine/mpp/flower-letters \
-H 'Content-Type: application/json' \
-H 'Authorization: Payment <serialized-mpp-credential>' \
--json @flower-letter-body.json
For x402 clients, use the same flower-letter JSON body with:
- Validate and quote:
POST https://postalform.com/api/machine/flower-letters/validate - Create and pay:
POST https://postalform.com/api/machine/flower-letters - Poll status:
GET https://postalform.com/api/machine/flower-letters/:id
Bulk mailing overview
Bulk mailing uses the same validate, create, and poll endpoints as the single-recipient machine order flow. The difference is that instead of one recipient address, you send one sender address plus a bulk object that describes the CSV list and content mode. Use this when you need to send one document to many recipients at once, or when you need merge fields to dynamically generate documents with recipient-specific content from a CSV list. Dynamic generation for merge fields is handled server-side within PostalForm and does not require any additional PDF generation or sanitization from the agent.
Required bulk fields:
csv_content: the raw CSV textcontent_mode: one oftext,html, orpdf
Optional bulk fields:
campaign_nametemplate_text(required whencontent_mode="text")template_html(required whencontent_mode="html")
Content modes:
text: PostalForm merges{{column_name}}variables into plain text and renders one PDF letter per row.html: PostalForm merges{{column_name}}variables into trusted styled HTML and renders one PDF letter per row.pdf: PostalForm reuses the top-levelpdffor every row. There is no merge-field support in this mode.
CSV rules:
- Required columns:
line1,city,state,zip - Optional address columns:
line2,recipient_name - Any other columns become merge fields available to
template_textortemplate_html
Bulk requests still require one sender address, chosen the same way as single-recipient machine orders:
- Loqate sender:
sender_address_id,sender_address_type="Address",sender_address_text - Manual sender:
sender_address_type="Manual",sender_address_manual
When bulk is present:
- do not send
recipient_nameor recipient address fields - validation responses include
bulk.recipient_countandbulk.content_mode - create and poll responses may include
campaign_url - each CSV row becomes its own mailed item with independent tracking and final delivery state on the campaign dashboard
Address strategy (manual vs autocomplete)
The payload guidance below applies to both machine REST paths. The core order payload is the same for x402 and MPP.
Machine orders can now describe either:
mailpiece_type: "letter"(default)mailpiece_type: "postcard"withpostcard_size: "4x6" | "6x9" | "11x6"
For postcard orders, keep pdf exactly as you use it today, but treat it as the fully composed postcard PDF.
Postcard PDF requirements:
- Use a 2-page PDF.
- Page 1 is the artwork side.
- Page 2 is the mailing side. You can place backside artwork or a non-address message there, but do not place sender/recipient names, return or delivery addresses, indicia, or barcode data in the PDF. PostalForm fills the mailing block automatically.
- The selected
postcard_sizemust match the exact bleed template dimensions shown in the postcard PDF guidelines.
For each party (sender + recipient), choose exactly one strategy:
- Manual address: best when you already have
line1,city,state, andzip. - Autocomplete (Loqate ID): best when you have incomplete address info and want the agent to resolve the full address autonomously.
Rules:
countryCodemay be omitted for manual addresses; PostalForm defaults it toUS.- Loqate address IDs carry their country prefix (for example
US|...,CA|...,GB|...). - Do not send both a Loqate id and a manual address for the same party. Pick one and set
*_address_typeaccordingly. - There is no automatic fallback from Loqate verification to manual later in the pipeline. If you want manual, send manual.
Option A: Manual address input (full address known)
If you already have the full address fields, you can skip autocomplete entirely and provide a manual address.
Manual addresses must include:
line1(street address)line2(optional unit/suite)citystate(required for US/Canada; use the state, province, or region where applicable)zip(ZIP, ZIP+4, or postal code)countryCode(optional two-letter code; defaults toUS)
In the machine order payload:
- Set
sender_address_type: "Manual"and includesender_address_manual. - Set
recipient_address_type: "Manual"and includerecipient_address_manual.
Example manual address object:
{
"line1": "123 Main St",
"line2": "Apt 4",
"city": "Springfield",
"state": "IL",
"zip": "62701",
"countryCode": "US"
}
Find sender and recipient addresses (Loqate IDs)
If you don't have a complete address (or want a convenience/autofill flow), use Loqate "Address" IDs
for sender_address_id and recipient_address_id.
Address suggestions can include two types:
type="Address": a deliverable mailing address you can use directly.type="Container": a building/complex (for example an apartment building). You must drill down to anAddress(suite/unit) before placing an order.
Recommended agent flow (via MCP tool):
- Call
postalform.search_addresseswithquery(min 3 chars) and optionalcountry_code(defaults toUS). - If you see
type="Container"results, ask for the unit/suite and call again withcontainer=<id>and a refinedquery. - Select a suggestion where
type="Address". Use:
id->*_address_idtype->*_address_type(must be"Address"for Loqate-based verification)${text}, ${description}->*_address_text
Example search:
{
"name": "postalform.search_addresses",
"arguments": {
"target": "recipient",
"query": "1600 Amphitheatre Pkwy, Mountain View"
}
}
Example drill-down (Container -> Address):
{
"name": "postalform.search_addresses",
"arguments": {
"target": "recipient",
"query": "Apt 4",
"container": "US|LP|Pz0_Qj4_bGJg|123456|99_ENG"
}
}
Note:
- If you send a
Containerid into the order payload, Loqate verification will fail later and the order will not mail. - If you already have a complete address (line1/city/state/zip/countryCode), you can use manual address input instead of Loqate IDs.
Draft an order payload (x402)
Required fields:
request_id(UUID, used for idempotency across one logical mailing attempt; reuse it for payment retries and generate a fresh one for a legitimate second order)buyer_namebuyer_email(required; used as Stripereceipt_emailso receipts are delivered to the buyer)- exactly one document source:
pdf(recommended canonical format:{ "upload_token": "..." }; also accepts{ download_url, file_id }, adata:application/pdf;base64,...URL, or an allowlisted https URL), orletter(a string or object that PostalForm renders server-side to PDF; optionalformatistext,html,markdown, orrtf, defaulttext), orform(a structured workflow form payload discovered throughGET /api/machine/forms)
- Sender:
sender_nameand either:- Loqate:
sender_address_id,sender_address_type="Address",sender_address_text, or - Manual:
sender_address_type="Manual",sender_address_manual
- Loqate:
- Recipient:
recipient_nameand either:- Loqate:
recipient_address_id,recipient_address_type="Address",recipient_address_text, or - Manual:
recipient_address_type="Manual",recipient_address_manual
- Loqate:
Common options:
double_sided(defaulttrue)color(defaultfalse)mail_class(standard,priority,express)certified(defaultfalse; basic Certified Mail, First Class only)certified_return_receipt(defaultfalse; Electronic Return Receipt add-on for Certified Mail only)mailpiece_type(letterorpostcard; defaultletter)postcard_size(4x6,6x9,11x6; required whenmailpiece_type="postcard")
For Certified Mail, set certified=true to request basic Certified Mail. Add certified_return_receipt=true only when the sender also wants the USPS Electronic Return Receipt delivery record showing the recipient's signature. If certified=false, PostalForm ignores certified_return_receipt.
Postcard behavior:
- Keep using the same endpoints and payment handshake.
- Provide
mailpiece_type: "postcard"and a validpostcard_size. - Use
pdfas the final postcard PDF, not a template id or text-only draft. - Do not typeset sender/recipient names or mailing addresses into the postcard PDF. Provide sender/recipient data in the order fields and PostalForm places the mailing data automatically.
- Lob-routed international postcards require
postcard_size="4x6"and a U.S. sender/return address. PostalForm routes larger international postcards or international postcards with non-U.S. return addresses to PostGrid before provider submission. - Follow the published postcard PDF guidelines and template downloads:
- PostalForm normalizes postcard mail options server-side to:
color=truedouble_sided=truemail_class="standard"certified=falsecertified_return_receipt=falsesignature_required=false
You can mix-and-match:
- Manual sender + Loqate recipient
- Loqate sender + manual recipient
Example request:
{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"pdf": {
"download_url": "https://example.oaiusercontent.com/file.pdf",
"file_id": "file_abc123"
},
"file_name": "letter.pdf",
"sender_name": "Sender Example",
"sender_address_id": "US|LP|Pz0_Qj4_bGJg|16074807|13_ENG",
"sender_address_type": "Address",
"sender_address_text": "123 Sender St, Springfield, IL 62701",
"recipient_name": "Recipient Example",
"recipient_address_id": "US|LP|Pz0_Qj4_bGJg|199825276|99_ENG",
"recipient_address_type": "Address",
"recipient_address_text": "456 Recipient Ave, Springfield, IL 62701",
"double_sided": true,
"color": false,
"mail_class": "standard",
"certified": false
}
Example request (server-rendered Markdown letter):
{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"letter": {
"format": "markdown",
"title": "Account notice",
"body": "# Account notice\n\nPlease review the enclosed account update and contact us with any questions.",
"signature": "Sender Example"
},
"file_name": "account-notice.pdf",
"sender_name": "Sender Example",
"sender_address_id": "US|LP|Pz0_Qj4_bGJg|16074807|13_ENG",
"sender_address_type": "Address",
"sender_address_text": "123 Sender St, Springfield, IL 62701",
"recipient_name": "Recipient Example",
"recipient_address_id": "US|LP|Pz0_Qj4_bGJg|199825276|99_ENG",
"recipient_address_type": "Address",
"recipient_address_text": "456 Recipient Ave, Springfield, IL 62701",
"double_sided": true,
"color": false,
"mail_class": "standard",
"certified": false
}
Example request (manual addresses):
{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"pdf": {
"download_url": "https://example.oaiusercontent.com/file.pdf",
"file_id": "file_abc123"
},
"file_name": "letter.pdf",
"sender_name": "Sender Example",
"sender_address_type": "Manual",
"sender_address_manual": {
"line1": "123 Sender St",
"line2": "Apt 4",
"city": "Springfield",
"state": "IL",
"zip": "62701"
},
"recipient_name": "Recipient Example",
"recipient_address_type": "Manual",
"recipient_address_manual": {
"line1": "456 Recipient Ave",
"line2": "",
"city": "Springfield",
"state": "IL",
"zip": "62701"
},
"double_sided": true,
"color": false,
"mail_class": "standard",
"certified": false
}
Example request (postcard):
{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"pdf": {
"download_url": "https://example.oaiusercontent.com/postcard.pdf",
"file_id": "file_postcard_123"
},
"file_name": "postcard.pdf",
"mailpiece_type": "postcard",
"postcard_size": "6x9",
"sender_name": "Sender Example",
"sender_address_id": "US|LP|Pz0_Qj4_bGJg|16074807|13_ENG",
"sender_address_type": "Address",
"sender_address_text": "123 Sender St, Springfield, IL 62701",
"recipient_name": "Recipient Example",
"recipient_address_id": "US|LP|Pz0_Qj4_bGJg|199825276|99_ENG",
"recipient_address_type": "Address",
"recipient_address_text": "456 Recipient Ave, Springfield, IL 62701"
}
Example request (workflow form):
{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"form": {
"slug": "5471",
"fields": {
"foreign_corporation_period_begin": "01/01/2025",
"foreign_corporation_period_end": "12/31/2025",
"field_5": "Example Filer Inc.",
"field_11": "12-3456789"
}
},
"file_name": "Form-5471.pdf",
"sender_name": "Sender Example",
"sender_address_type": "Manual",
"sender_address_manual": {
"line1": "123 Sender St",
"line2": "Apt 4",
"city": "Springfield",
"state": "IL",
"zip": "62701"
},
"recipient_name": "Recipient Example",
"recipient_address_type": "Manual",
"recipient_address_manual": {
"line1": "456 Recipient Ave",
"line2": "",
"city": "Springfield",
"state": "IL",
"zip": "62701"
},
"double_sided": true,
"color": false,
"mail_class": "standard",
"certified": false
}
Workflow form rules:
- Discover first with
GET /api/machine/forms, then fetch the selected schema withGET /api/machine/forms/{slug}/schema. - Send
form.slugandform.fields; field keys must come from the selected schema. - For compound fields, send a nested object keyed by the compound subfield ids from the schema.
- If the schema lists required attachments, include them in
form.attachmentswithidandbase64; data URLs are accepted. - Do not send more than one of
pdf,letter, orformin the same single-recipient order. - Workflow forms and server-rendered letters are letter-only. Postcards still require a fully composed
pdf. - Validation renders and validates the filled workflow PDF server-side before the x402 or MPP payment challenge is issued.
Example request (bulk mailing with merge fields):
{
"request_id": "8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b",
"buyer_name": "Agent Owner",
"buyer_email": "owner@example.com",
"sender_name": "Sender Example",
"sender_address_id": "US|LP|Pz0_Qj4_bGJg|16074807|13_ENG",
"sender_address_type": "Address",
"sender_address_text": "123 Sender St, Springfield, IL 62701",
"double_sided": true,
"color": false,
"mail_class": "standard",
"certified": false,
"bulk": {
"campaign_name": "April Outreach",
"csv_content": "recipient_name,line1,line2,city,state,zip,account_number\nJane Doe,123 Main St,,Springfield,IL,62701,ACCT-100\nJohn Smith,456 Oak Ave,Apt 2,Chicago,IL,60601,ACCT-200",
"content_mode": "text",
"template_text": "Hello {{recipient_name}}, your account reference is {{account_number}}."
}
}
For HTML bulk mail, replace content_mode with html and send template_html.
For shared-PDF bulk mail, replace content_mode with pdf and provide the top-level pdf field. That same PDF will be mailed to every recipient row.
Troubleshooting (common x402 API responses)
422(invalid_request): payload validation failed. The response includeserrors[]withpath,message, and machine hints (hint,fix_examples) when available.- Manual addresses: US requires a valid two-letter state and ZIP/ZIP+4; Canada requires a province and Canadian postal code; other countries require
line1,city, and postal code. - Loqate addresses:
*_address_typemust be"Address"(not"Container"), and ids must be supported Loqate ids. - Don’t send
*_address_manualunless*_address_type="Manual". - Bulk mail:
bulk.csv_contentmust parse into at least one valid row withline1,city,state, andzip. - Bulk text mode:
bulk.template_textis required whenbulk.content_mode="text". - Bulk HTML mode:
bulk.template_htmlis required whenbulk.content_mode="html". - Bulk shared-PDF mode: the top-level
pdfis required whenbulk.content_mode="pdf". - Single-recipient document source: send exactly one of
pdf,letter, orform. - If
mailpiece_type="postcard", you must send a validpostcard_size.
- Manual addresses: US requires a valid two-letter state and ZIP/ZIP+4; Canada requires a province and Canadian postal code; other countries require
422(missing_buyer_email):buyer_emailwas missing when attempting machine payment; this email is required to set Stripereceipt_email.409(request_id_mismatch): the samerequest_idwas reused with a different payload.402(payment_required): pay and retry withPAYMENT-SIGNATURE(or usepurl). Single-recipient drafts may includepreview_url, a signed short-lived PDF URL for reviewing the generated or uploaded draft before payment.429(rate_limited): slow down address searches and retry later.
Pay autonomously (MPP endpoint)
Use the MPP path when your client already speaks MPP and can respond to WWW-Authenticate: Payment ... challenges.
The same MPP challenge flow applies to both single-recipient orders and bulk campaigns. For bulk mail, keep the same bulk object on validate, on the unpaid create call, and on the paid retry.
High-level flow:
- Quote and validate with
POST https://postalform.com/api/machine/mpp/orders/validate. - Expect a
200response withprotocol: "mpp"andmethods: ["tempo", "stripe_spt"]. - Create the order with
POST https://postalform.com/api/machine/mpp/ordersand no payment credential. - Expect
402with one or moreWWW-Authenticate: Payment ...challenges. PostalForm typically returns both atempochallenge and astripechallenge for the same order. - Choose exactly one payment instrument and retry the same request body and
request_idwithAuthorization: Payment .... - Read the
Payment-Receiptheader from the success response. A paid request may still return202 settled_pending_webhookwhile Stripe finalizes the underlying PaymentIntent. - Poll
GET https://postalform.com/api/machine/mpp/orders/:iduntilpayment_statusbecomespaid.
MPP payment required response
When PostalForm returns 402 payment_required on the MPP endpoint:
- Do not create a new order.
- Reuse the same
request_id. - Reuse the exact same JSON request body.
- Keep the canonical
order_idreturned by PostalForm. A laterrequest_idmay alias back to that existing unpaid order. - If
preview_urlis present, use it to review the generated or uploaded draft PDF before paying. - Read the
WWW-Authenticate: Payment ...headers and choose exactly one supported method. - Retry the same
POST /api/machine/mpp/orderscall withAuthorization: Payment ....
Choosing a payment instrument
- Tempo: use an MPP-capable Tempo client such as
mppxto settle the Tempo challenge on chain. - Stripe SPT: use your own Stripe integration to mint a
shared_payment.granted_token(spt_...) from a customer-approved card or Link payment method, then answer the Stripe MPP challenge with that token.
Stripe SPT checklist
To pay the Stripe challenge successfully, your agent should:
- Parse the
stripechallenge from theWWW-Authenticateheaders. - Read
amount,currency, andmethodDetails.networkIdfrom that challenge. - Mint or obtain a buyer-approved granted token (
spt_...) through Stripe for the same amount, the same currency, and the same sellernetwork_id. - Serialize the chosen Stripe challenge plus
{ "spt": "..." }into one MPP credential and send that full value back as theAuthorizationheader. - Retry the exact same
POST /api/machine/mpp/ordersrequest body and the exact samerequest_id.
Important details:
- PostalForm does not accept a bare
spt_...token by itself. The retry header must be the full serialized MPP credential (for example,Credential.serialize(...)), not just the token string. - For Stripe challenges,
request.amountis in the smallest currency unit (for USD, cents). - In current PostalForm deployments the Stripe challenge commonly advertises
networkId: "internal"andpaymentMethodTypes: ["card", "link"], but clients should trust the challenge payload instead of hard-coding those values. - A Stripe SPT minted for the wrong
network_id, amount, currency, or expiry window can fail even if the token string itself looks valid.
See Stripe Shared Payment Tokens for the seller-side token lifecycle.
Important:
- Do not send raw card details (PAN/CVC/expiry) to PostalForm. PostalForm only accepts the resulting
Authorization: Payment ...credential, which may embed anspt_...token for the Stripe path. - The first successful MPP create call returns order state plus a
Payment-Receipt; the order may still remain insettled_pending_webhookbriefly until the Stripe webhook marks it fully paid.
Example: serialize a Stripe challenge into Authorization
If you already have an MPP-aware client but need the exact retry shape, the Stripe path looks like:
import { Challenge, Credential } from 'mppx'
const endpoint = 'https://postalform.com/api/machine/mpp/orders'
const body = {
request_id: '8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b',
buyer_name: 'Agent Owner',
buyer_email: 'owner@example.com',
sender_name: 'Sender Example',
sender_address_type: 'Address',
sender_address_id: 'US|LP|Pz0_Qj4_bGJg|16074807|13_ENG',
sender_address_text: '123 Sender St, Springfield, IL 62701',
bulk: {
campaign_name: 'April Outreach',
csv_content: 'recipient_name,line1,city,state,zip\\nJane Doe,123 Main St,Springfield,IL,62701',
content_mode: 'text',
template_text: 'Hello {{recipient_name}}.',
},
}
const unpaid = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (unpaid.status !== 402) {
throw new Error(`Expected 402, received ${unpaid.status}`)
}
const challenges = Challenge.fromResponseList(unpaid)
const stripeChallenge = challenges.find((challenge) => challenge.method === 'stripe')
if (!stripeChallenge) {
throw new Error('No stripe challenge returned')
}
const methodDetails = stripeChallenge.request.methodDetails as {
networkId?: string
paymentMethodTypes?: string[]
}
const spt = await mintSharedPaymentGrantedToken({
amount: stripeChallenge.request.amount,
currency: stripeChallenge.request.currency,
networkId: methodDetails.networkId,
externalId: body.request_id,
})
const authorization = Credential.serialize(
Credential.from({
challenge: stripeChallenge,
payload: { spt },
})
)
const paid = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorization,
},
body: JSON.stringify(body),
})
mintSharedPaymentGrantedToken(...) is your own Stripe-side token provisioning step. The critical part is that you reuse the challenge you just received, serialize that challenge plus { spt }, and replay the same request body.
Test mode: mint a Stripe SPT that matches the challenge
For sandbox or CI testing, Stripe's granted-token test helper can mint an spt_... token that matches the challenge:
curl https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens \
-u "$STRIPE_SECRET_KEY:" \
-H "Stripe-Version: 2026-03-04.preview" \
-d payment_method=pm_card_visa \
-d "usage_limits[currency]"=usd \
-d "usage_limits[max_amount]"=340 \
-d "usage_limits[expires_at]"=1775880017 \
-d "seller_details[network_id]"=internal \
-d "seller_details[external_id]"=order_mpp_1
Replace usd, 340, 1775880017, and internal with the values from the live Stripe challenge. In live mode, your agent should obtain a real buyer-approved spt_... through its own Stripe flow instead of the test helper.
Example: Tempo with mppx
If your agent already uses mppx, a Tempo payment looks like:
mppx \
https://postalform.com/api/machine/mpp/orders \
--account my-tempo-wallet \
--json-body '{"request_id":"8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b","buyer_name":"Agent Owner","buyer_email":"owner@example.com","pdf":{"download_url":"https://example.oaiusercontent.com/file.pdf","file_id":"file_abc123"},"sender_name":"Sender Example","sender_address_id":"US|LP|Pz0_Qj4_bGJg|16074807|13_ENG","sender_address_type":"Address","sender_address_text":"123 Sender St, Springfield, IL 62701","recipient_name":"Recipient Example","recipient_address_id":"US|LP|Pz0_Qj4_bGJg|199825276|99_ENG","recipient_address_type":"Address","recipient_address_text":"456 Recipient Ave, Springfield, IL 62701","double_sided":true,"color":false,"mail_class":"standard","certified":false}' \
--include \
--verbose
mppx will parse the WWW-Authenticate: Payment ... response, choose a compatible challenge, pay it, and retry the same request with Authorization: Payment ....
MPP-specific troubleshooting
- Retried request still returns
402: you probably changedrequest_idor the JSON body, chose the wrong challenge, or sent a barespt_...instead of a serialized credential. - Stripe verification fails after you minted an SPT: ensure the granted token was minted for the challenge's exact
amount,currency, andmethodDetails.networkId. 202 settled_pending_webhook: payment was accepted. Keep pollingGET /api/machine/mpp/orders/:iduntilpayment_statusbecomespaid; do not mint a second token or create a new order.- Bulk campaigns follow the same rule set. A bulk MPP retry must reuse the identical
bulkobject andrequest_id.
Pay autonomously (x402, recommended: purl)
Stripe maintains an agent-facing wallet CLI called purl that can fully automate the 402 flow: it reads PAYMENT-REQUIRED, creates a payment, and retries the request with PAYMENT-SIGNATURE.
Install the CLI (requires Rust):
git clone https://github.com/stripe/purl
cd purl
cargo install --path cli
Example:
export POSTALFORM_URL="https://postalform.com/api/machine/orders"
export EVM_PRIVATE_KEY="0x..."
purl \
--private-key "$EVM_PRIVATE_KEY" \
--network eip155:8453 \
--max-amount 5000000 \
--output-format json \
-X POST \
--json '{"request_id":"8c1a1b58-2c8f-4f4f-9c46-2c1ac32d7a1b","buyer_name":"Agent Owner","buyer_email":"owner@example.com","pdf":{"download_url":"https://example.oaiusercontent.com/file.pdf","file_id":"file_abc123"},"sender_name":"Sender Example","sender_address_id":"US|LP|Pz0_Qj4_bGJg|16074807|13_ENG","sender_address_type":"Address","sender_address_text":"123 Sender St, Springfield, IL 62701","recipient_name":"Recipient Example","recipient_address_id":"US|LP|Pz0_Qj4_bGJg|199825276|99_ENG","recipient_address_type":"Address","recipient_address_text":"456 Recipient Ave, Springfield, IL 62701","double_sided":true,"color":false,"mail_class":"standard","certified":false}' \
"$POSTALFORM_URL"
Notes:
--max-amountis a safety cap in atomic units (USDC has 6 decimals).purlwill refuse to pay if the server asks for more than this cap.--networkmust match the network returned inPAYMENT-REQUIRED(for Base mainnet useeip155:8453; for Base Sepolia useeip155:84532).- Your agent can retry the same
request_idsafely. PostalForm rejects the samerequest_idif the payload changes. - If you want to send the same document to the same addresses again after a prior order is paid or settled, generate a fresh
request_id.
Pay autonomously (x402, JavaScript)
If you prefer to implement the x402 handshake directly, use the official x402 packages:
@x402/corefor the HTTP protocol wrapper@x402/evmfor EVM signing (USDC)
High-level flow:
fetch(POST /api/machine/orders)-> expect402.- Decode
PAYMENT-REQUIRED. - Create a payment payload.
- Retry request with
PAYMENT-SIGNATURE. - Read
PAYMENT-RESPONSEand persist the settlement tx hash.
Track fulfillment
Tracking depends on the payment path:
- x402: poll
GET https://postalform.com/api/machine/orders/:id - MPP endpoint: poll
GET https://postalform.com/api/machine/mpp/orders/:id
For x402, after a successful payment, poll using either the canonical order_id returned by PostalForm or any aliased request_id:
curl -sS "https://postalform.com/api/machine/orders/<request_id>"
The response includes is_paid, current_step, x402/MPP settlement metadata, and the normalized mailpiece_type / postcard_size so an agent can confirm what was actually drafted.
It also includes order_complete_url with the following behavior:
- Before payment is settled:
"Order URL will be returned once payment is settled." - After payment is settled (
is_paid=true):https://postalform.com/order/complete?order_id=<request_id>
For bulk mailing orders, the response may also include campaign_url, which links to the bulk campaign dashboard. Use that dashboard for per-recipient mailed-item statuses, delivery events, and tracking metadata.
Tools for agents
If you need help finding address IDs or handling PDF uploads, the PostalForm MCP server can help:
- MCP endpoint:
https://postalform.com/mcp - Tools:
postalform.search_addresses(get*_address_idvalues)postalform.create_pdf_upload(get anupload_tokenif you cannot pass a file download URL)postalform.list_forms(list published workflow forms available to agents)postalform.get_form_schema(fetch workflow fields, dependencies, attachments, and submission metadata)postalform.create_form_order_draft(create a hosted-checkout form draft from structured workflow JSON)postalform.create_machine_order(create a machine-paid PDF, letter, or workflow-form order; returns MPP/x402 challenges and accepts the paid retry credential)postalform.get_order_status(poll status for non-machine draft/checkout flows)
Use MCP draft tools when a human will finish payment in hosted checkout. Use postalform.create_machine_order or the machine REST endpoints when the agent should fill the PDF/letter/form payload, create the order, and answer an x402 or MPP payment challenge itself.
If your agent uses workflow tools (postalform.list_forms + postalform.get_form_schema), treat checkout_flow in schema responses as optional informational metadata for PostalForm web routing. It does not change MCP call order.
Refunds
Stripe supports refunds for crypto pay-ins in live mode. If you need to reverse a machine payment, you can refund the Stripe PaymentIntent and have USDC sent back to the payer wallet.
Contact support@postalform.com if you need help enabling machine payments or wiring up a facilitator for your deployment.