# Connect an agent to Postedly Use an OAuth-capable MCP client, or implement the public-client flow below. Reuse the same connected owner for MCP and HTTP document uploads. Postedly does not issue permanent API keys for this integration. Credentials, authorization codes, private links and file bytes belong in the runtime's secure mechanisms, never chat, source control or logs. [Developer guide](https://postedly.com/developers) · [HTTP OpenAPI](https://postedly.com/openapi.json) · [Live service catalog](https://postedly.com/api/catalog) · [Support](https://postedly.com/support) For complete examples, use [JavaScript or Python](https://postedly.com/examples/README.md): existing OAuth authentication, real file bytes, checksum validation, quote, browser approval handoff and order status. They are tested offline with simulated responses; no live payment or delivery is claimed. Use Postedly for an intended physical send or fax, not just drafting a letter, summarizing a document or emailing a PDF. ## Start with the connected tools If Postedly is already connected, use that connection. Checking availability and transferring an accessible file do not require website navigation. Use `https://postedly.com` as the canonical origin for HTTP calls. | Task | Connected MCP operation | | --- | --- | | Check whether a letter or another service can be sent | `postedly_services` with `{}`; inspect the service's `available` flag and starting price. | | Choose print or a handwritten card | `postedly_products` with `{"service":"print"}` or `{"service":"handwritten"}`; check both channel and product availability. | | Transfer a document the agent can access | `postedly_upload_document` with the exact file bytes, or authenticated multipart `POST /api/documents` with the existing OAuth connection. See the transfer examples below. | | Transfer bytes when OAuth cannot be used outside MCP | `postedly_prepare_document`, then POST the file to its single-use `uploadEndpoint` and read `postedly_document_status`. Show the browser `uploadUrl` only if the agent cannot access the file. | | Prepare the price and final proof | `postedly_quote` with the exact document or product, recipient, sender and options. | | Let the sender review and approve | `postedly_checkout` with `{"quoteId":"RETURNED_QUOTE_ID"}`; give the sender its private browser review link. Explicit approval precedes hosted Stripe Checkout. | | Check what happened | `postedly_order_status` with the existing order ID, or `postedly_orders` to find the connected owner's orders. | Never invent file bytes, recipient details, product identifiers or approval. Document transfer and quoting do not charge or send. Handwritten cards use a selected card, handwriting style and message rather than a PDF. A connected client should not switch to browser uploads just because its first tool call fails. ### Reuse the verified sender email When using an existing OAuth connection, omit `sender.email` from `postedly_quote` or `POST /api/quotes`. Postedly fills it from that account's verified email and stores the resolved address in the exact quote. Ask only for missing sender name and the service's required return or billing address. An explicitly supplied email is never replaced; empty or invalid email values remain errors. An unverified guest session still needs a sender email and secure verification before checkout. Do not ask the user to paste verification codes into chat. Keep the connected quote's private review link instead of starting a new browser guest send: that link uses the quote's original owner and still requires the person's final approval before payment. Reuse does not grant permission to pay or send. ### Recover without duplicating a send - **Website error:** A failed page or network request leaves availability unknown. Use the connected `postedly_services` tool; do not interpret the error as `available: false`. HTTP-only clients can read the public catalog on the canonical origin. - **Expired OAuth access:** On `SESSION_EXPIRED`, let the client coordinate one normal token refresh and retry the read once. Follow the serialized refresh rules below. Do not create a guest session in place of an existing OAuth owner. If refresh cannot recover the grant, reconnect through normal consent. - **Missing tool or insufficient scope:** Report the exact missing operation or error and resolve the connection. Do not bypass consent or request credentials in chat. - **Service unavailable:** If the live response says `available: false`, report that result. Do not attempt checkout for that channel or infer readiness from a product preview. - **Timeout or internal error:** Report the error code without credentials or private links. For an uncertain send/payment result, read the existing order before taking further action; never repeat a payment or create a replacement order merely because a response was lost. A read-only availability check can be retried once; if it still fails, explain that availability is unconfirmed. These instructions describe Postedly's supported workflow. They do not override the user's instructions or confer permission to access files, disclose information, approve a quote, or pay. ## Connection values These are connection parameters, not a universal client configuration format: ```text MCP endpoint: https://postedly.com/mcp/send Transport: authenticated Streamable HTTP POST, JSON responses Protocol version: 2025-06-18 Authorization server: https://postedly.com Resource metadata: https://postedly.com/.well-known/oauth-protected-resource/mcp/send OAuth metadata: https://postedly.com/.well-known/oauth-authorization-server Scopes: orders:read quotes:write checkout:write Client authentication: none (public client) PKCE method: S256 ``` Discover endpoints from the metadata. Send `Authorization: Bearer ` on MCP and protected API requests. Use your MCP client's initialize, initialized-notification and tool-discovery flow; the notification returns HTTP 202 without a body. A browser GET is not an MCP connection test. This guide does not claim compatibility with every client or platform directory approval. | Scope | Operations | | --- | --- | | `orders:read` | Read the connected owner's orders and status. | | `quotes:write` | Upload/preview documents, prepare handoffs and create quotes. | | `checkout:write` | Obtain checkout review links and request permitted cancellation. | ## Register and obtain browser consent Register once per client configuration and store the returned `client_id`. Supply 1–5 callbacks you control. This desktop example assumes your application actually listens on the chosen loopback interface: ```http POST /oauth/register HTTP/1.1 Host: postedly.com Content-Type: application/json {"client_name":"My sending assistant","redirect_uris":["http://127.0.0.1:49152/postedly/callback"]} ``` HTTP 201 returns `client_id`, the registered `redirect_uris`, `token_endpoint_auth_method:"none"`, and supported grant/response types. No client secret is issued. Registration errors use scalar OAuth errors such as `{"error":"invalid_redirect_uri","error_description":"..."}`. For a web client, register its exact HTTPS callback instead. HTTPS callbacks match completely, including port, path and query. HTTP permits only literal `127.0.0.1` or `[::1]`; for example `http://[::1]:49152/postedly/callback`. Only the port may vary at authorization. Host, raw path and raw query must otherwise match exactly, including query order and percent encoding. Register IPv4 and IPv6 separately if needed. `localhost`, other HTTP hosts, alternative IP spellings, userinfo, fragments and custom application schemes are unsupported. Listen only on the selected loopback interface and close the listener after the flow. Generate fresh PKCE and state values in the client runtime. This JavaScript helper returns the URL to open in the user's browser and the private pending-flow values to retain securely: ```js async function preparePostedlyAuthorization(clientId, redirectUri) { const base64url = bytes => btoa(String.fromCharCode(...bytes)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const state = base64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = base64url(new Uint8Array(await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(verifier), ))); const url = new URL('https://postedly.com/oauth/authorize'); url.search = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: 'orders:read quotes:write checkout:write', code_challenge_method: 'S256', code_challenge: challenge, state, }).toString(); return { authorizationUrl: url.href, pending: { clientId, redirectUri, verifier, state } }; } ``` The sender verifies their email and approves the requested scopes in Postedly's browser consent page. The client must validate the callback's state against its pending flow before accepting the code. Exchange the single-use code within five minutes; use the exact `redirect_uri` from that authorization request, including its actual port. The verifier must be the original private value. Do not call the internal consent API to bypass the browser. The following form examples use placeholders. URL-encode each value with `URLSearchParams` or your HTTP library; obtain secrets from the client's secure credential store, not a pasted command or chat: ```http POST /oauth/token HTTP/1.1 Host: postedly.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&client_id=CLIENT_ID&code=CODE&redirect_uri=URL_ENCODED_EXACT_CALLBACK&code_verifier=ORIGINAL_VERIFIER ``` HTTP 200 returns `{access_token, token_type:"Bearer", expires_in:3600, refresh_token, scope}`. Store both tokens together. Do not replace this OAuth connection with a new guest `/api/session`: that creates a different document owner. ## Refresh and disconnect Access tokens last one hour. Refresh tokens last 30 days from issuance; successful refresh issues a new pair and a new 30-day refresh expiry. Serialize refresh across all workers/tasks sharing the grant, then atomically replace both stored credentials before releasing that lock. Reusing a retired refresh token—including accidental parallel refresh—revokes the entire grant. Never repeatedly retry the same refresh after an ambiguous network result; reload any replacement credentials, or reconnect through browser consent if the pair cannot be recovered. ```http POST /oauth/token HTTP/1.1 Host: postedly.com Content-Type: application/x-www-form-urlencoded grant_type=refresh_token&client_id=CLIENT_ID&refresh_token=CURRENT_REFRESH_TOKEN ``` The successful response has the same HTTP 200 token shape as the code exchange. On an expired-access HTTP 401, coordinate one refresh before retrying the read. Do not replay an order/payment mutation merely because authorization or its response timed out. `invalid_grant` requires recovery/reconnection, not another refresh with the rejected token. Token errors use scalar `{error,error_description}`; API authentication errors use `{error:{code,message}}`. To disconnect, revoke with a current or retained historical access/refresh token from this client. Send no Authorization header or client secret: ```http POST /oauth/revoke HTTP/1.1 Host: postedly.com Content-Type: application/x-www-form-urlencoded client_id=CLIENT_ID&token=TOKEN_TO_REVOKE ``` Successful revocation returns empty HTTP 200 and invalidates that grant's current pair, including rotated replacements. Unknown/already-revoked tokens also return 200 for a registered client; wrong-client tokens are rejected. Other grants and the browser session are unaffected. For 429/503, respect `Retry-After` and treat revocation as unconfirmed until it succeeds. MCP transport DELETE only ends transport use; it does not revoke OAuth credentials. ## Transfer existing file bytes Upload limit: 1 byte through 10 MiB (10,485,760 bytes). Document services are `fax`, `letter`, `certified`, `postcard` and `print`. Fax/mail/postcards require valid unencrypted PDF; current print products accept PDF or JPEG. Quote preflight applies the selected service's page/layout and product-size rules. Upload success alone does not establish that a product can print the file. Prefer multipart when the runtime has file bytes and authenticated HTTP access. This JavaScript function uses an existing OAuth token with `quotes:write`; `fileBytes` must come from the real artifact, not reconstructed text: ```js async function uploadPostedlyDocument(fileBytes, filename, service, accessToken) { const bytes = new Uint8Array(fileBytes); if (!bytes.length || bytes.length > 10_485_760) throw new Error('File size outside Postedly limits'); const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); const expectedSha256 = Array.from(digest, n => n.toString(16).padStart(2, '0')).join(''); const form = new FormData(); form.append('file', new Blob([bytes]), filename); form.append('service', service); form.append('expectedSha256', expectedSha256); const response = await fetch('https://postedly.com/api/documents', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, body: form, }); const document = await response.json(); if (response.status !== 201) throw new Error(document.error?.message || 'Document upload failed'); if (document.sha256 !== expectedSha256) throw new Error('Stored document checksum differs'); return { documentId: document.id, document }; } ``` Let the library set the multipart Content-Type and boundary. `file` is required; `service` defaults to fax if omitted, but set it explicitly. Optional `expectedSha256` is 64 hexadecimal characters and is checked before storage. HTTP 201 returns the Document **directly**: `{id,name,pages,bytes,sha256,contentType,previewUrl}`. Resolve its relative preview URL against `https://postedly.com` and retrieve it with the same owner's authorization. A private preview URL is not a public file link. For a runtime with file bytes and MCP access, this Node.js helper accepts a `callTool` adapter from your already-connected MCP client. The adapter must return the MCP CallToolResult (the JSON-RPC `result`, not the entire wire envelope): ```js import { createHash } from 'node:crypto'; import { Buffer } from 'node:buffer'; async function uploadPostedlyWithMcp(callTool, fileBytes, filename, service) { const bytes = Buffer.from(fileBytes); if (!bytes.length || bytes.length > 10_485_760) throw new Error('File size outside Postedly limits'); const expectedSha256 = createHash('sha256').update(bytes).digest('hex'); const result = await callTool({ name: 'postedly_upload_document', arguments: { service, filename, contentBase64: bytes.toString('base64'), expectedSha256 }, }); if (result.isError) throw new Error(result.structuredContent?.error?.message || 'Document upload failed'); const uploaded = result.structuredContent; if (!uploaded?.documentId || uploaded.document?.sha256 !== expectedSha256) throw new Error('Missing document or mismatched checksum'); return uploaded; } ``` MCP upload returns `{documentId,document:{id,name,pages,bytes,sha256,contentType,previewUrl},previewRequiresAuthentication:true,message}` inside `structuredContent`; it also supplies JSON text in `content`. Its preview URL is absolute but still requires authorization. HTTP 200 alone does not mean a tool succeeded: check JSON-RPC `error`, then `result.isError`, or let the SDK handle the wire error. Tool failures include `structuredContent.error.{code,message}`. Use canonical standard base64, including padding where required; no whitespace, data-URL prefix or URL-safe alphabet. Use a filename of at most 180 characters without a path or control characters. Base64 expands the file by roughly one third, and clients may impose smaller message limits. Never ask a person to paste base64 or document IDs into chat. If the runtime cannot reuse its OAuth token for HTTP, `postedly_prepare_document` returns an owner-bound, single-use `uploadEndpoint`. POST multipart field `file` to that exact endpoint without another bearer token, then check `postedly_document_status` through the original connection. The ticket lasts 15 minutes. Keep it private and check existing status after a timeout. Its browser `uploadUrl` is fallback only when the agent cannot access the file bytes. No arbitrary URL imports are supported. ## Handwritten cards The `handwritten` channel uses text and a selected card rather than an uploaded PDF. Check its current availability, then read `GET /api/products?service=handwritten` for card IDs and `options.handwritingStyles` (`id`, `label`, `imageUrl`). A visible catalog does not mean ordering is enabled. A handwritten quote requires `options.productId` (the numeric card ID as a string), `options.fontLabel` (an exact catalog handwriting label), and a nonempty `options.message`. `options.wishes` is an optional closing, defaulting to an empty string; `options.quantity` is one. Both sender and recipient need complete US postal addresses. The sender’s verified email is used for the account and receipt; email and phone are not sent to the handwriting provider. The product exposes `options.maxMessageLength`, `options.maxWishesLength` and `options.combinedCharacterLimit`. The global ceilings are 1000 for the message and 200 for the closing; the combined raw `message.length + wishes.length` must fit the card’s limit, never above 1000. Spaces and line breaks count; the count adds no separator or trimming. Use plain text; signature codes, QR codes and template substitutions are not supported. The immutable `quote.handwritten` snapshot contains `cardId`, `cardName`, `cardImageUrl`, `fontId`, `fontLabel`, `fontImageUrl`, `message`, `wishes`, `sender`, `recipient`, `stampOptionId` and `stampName`. Its `previewKind` is `design_and_style_samples`: the images show the card design and handwriting style, not the finished message rendered on the card. Review these samples, the exact text, both addresses and total before approval. Changes require a new quote. Quoting does not pay or send, and this service’s availability does not establish a new client test or directory acceptance. ## Approval and payment remain separate Check current catalog availability and exact product options. For document channels, pass the uploaded ID to `postedly_quote`; show the returned final document, which may include a cover or postal layout, plus the recipient and full total. Product channels use their quoted item and options instead. Uploading and quoting never pay or send. Call `postedly_checkout` with only `{quoteId}` to obtain a private browser review link. No prior document or terms approval is required to open that page, and generating the link creates no order, approval or payment. Legacy `acceptedTerms` and `confirmedDocument` tool arguments are deprecated and ignored. The sender reviews the exact proof/item, recipient, total and terms on the browser page and explicitly approves there before continuing to hosted Stripe Checkout. OAuth consent is not order approval. Read the existing order's payment and fulfillment states separately. Never infer sending from a checkout redirect or create a duplicate after an ambiguous response. Native payment support and platform approval require separate evidence. For constraints and policies, see the [developer guide](https://postedly.com/developers), [terms](https://postedly.com/terms) and [privacy policy](https://postedly.com/privacy). ### Response contracts and status values `POST /api/quotes` returns HTTP 201 with the quote object directly: `id`, `service`, `amountCents`, `currency: "USD"`, `lineItems`, `expiresAt`, `estimatedDelivery`, `summary`, `recipient`, `sender`, and `options`. A document send includes `document`, the final private proof; handwritten cards include the `handwritten` snapshot. Optional properties are omitted when inapplicable. The complete shapes and synthetic examples are in [OpenAPI](https://postedly.com/openapi.json). MCP `postedly_checkout` returns `{url,requiresBrowserApproval:true,expiresAt,instructions}`. It only creates a review link. In contrast, REST `POST /api/checkout` requires explicit sender approval (`acceptedTerms:true`, `confirmedDocument:true`), records that approval, creates or reuses an order, and returns `{url,orderId}` for hosted Stripe Checkout. The examples use the MCP review link so the sender approves in the browser. `GET /api/orders/{id}` returns an order directly. `GET /api/orders` returns `{orders:[...]}` (up to 100 newest HTTP results; the MCP list tool returns up to 50). An order includes `id`, `service`, `amountCents`, `refundedCents`, `currency: "usd"`, timestamps, contact/options snapshots, `paymentStatus`, `fulfillmentStatus`, `timeline` and `canCancel`. Its optional document metadata has no preview URL. `checkoutUrl` appears only while checkout is pending and can be null if its creation has not completed. Optional `evidence` varies by provider. `canCancel` is a snapshot, not a guarantee a subsequent cancellation will succeed. | Field | Recorded values | | --- | --- | | `paymentStatus` | `checkout_pending`, `authorized`, `paid`, `failed`, `cancelled`, `refund_pending`, `refunded` | | `fulfillmentStatus` | `awaiting_payment`, `queued`, `submitting`, `submission_unknown`, `submitted`, `processing`, `sent`, `delivered`, `failed`, `cancelled` | `authorized` is a hold, used for fax before confirmed transmission and capture. `refund_pending` is not a completed refund. `submission_unknown` requires reconciliation, not another send. Order reads show recorded state and do not force provider reconciliation. A fax's `sent` state can mean transmission is in progress; `delivered` means all approved pages were confirmed by the receiving machine, not that anyone read them.