Automate compliance mail from your CRM or support tool
By Justin Winter · Updated August 22, 2026
If a ticket, case, or account status needs to trigger a real certified letter and there's no pre-built paperplane integration for your tool, call the REST API directly: a webhook or scheduled job in your own backend detects the trigger condition, calls POST /v1/orders, and writes the resulting order_id and tracking number back onto the record — from $12.99 all-in for Certified Mail, $14.99 with an electronic Return Receipt.
paperplane already ships native integrations for the tools most teams reach for first — Zapier, n8n, Make, Google Sheets, Google Drive, Dropbox, Notion, and MCP for Claude, ChatGPT, and Cursor. See /integrations for those. This page is for the case those don't cover: a CRM, a support desk, or an internal tool that needs to trigger mail directly from its own backend or workflow engine, with no pre-built connector in the middle. The underlying surface is the same for every case — the REST API documented at /developers — so the pattern below is really just "how to wire that API into whatever you already run."
The general architecture
Three pieces, and paperplane is only the third one:
- A trigger. Something in your CRM or support tool changes state in a way that should produce a letter — a ticket gets tagged, a case field flips to a certain value, a record crosses a deadline. Most platforms can fire an outbound webhook on this; if yours can't, a scheduled job that polls for matching records on an interval works just as well.
- Your backend. A small handler (a serverless function, a queue worker, an existing internal service) receives the trigger, decides whether this specific event should actually produce a letter, checks whether it already has, and if not, builds the letter content and addresses from data already in your system.
- paperplane's REST API. Your backend calls
POST /v1/quotesto price the letter (optional but useful — it also surfaces a bad address before anything is created), thenPOST /v1/ordersto actually create it. The response carries either apayment_urlfor a human to approve, or, for a prepaid credit balance, a confirmed order straight away.
Nothing about this needs a persistent connection or special credentials — the API is keyless, and each order carries its own payment. That is what makes it embeddable in an arbitrary internal tool: there is no connector to install, just an HTTP call your existing backend is already capable of making.
What a sensible trigger condition looks like
The trigger should be specific enough that it fires only when a human would genuinely want a letter sent — not every ticket update. A workable example: a support ticket gets tagged formal-notice-required by an agent or a business rule (say, a customer has missed three payment reminders, or a dispute has escalated past a certain SLA). That tag is the signal; everything else — rendering the notice text, pulling the customer's mailing address from the CRM record, choosing Certified vs. Certified + Return Receipt — happens in your backend before the API call is made.
// Your backend — webhook handler for a ticket-tagged event
// (Zendesk trigger, Salesforce flow, or a scheduled job over your own DB)
async function onTicketTagged(ticket) {
if (!ticket.tags.includes('formal-notice-required')) return
// 1. Have you already mailed this? Check your own record first.
const existing = await db.mailOrders.findByIdempotencyKey(ticket.id + ':formal-notice')
if (existing) return // already sent — webhook retried, do nothing
// 2. Price it (optional, but cheap and catches bad addresses early)
const quote = await fetch('https://sendpaperplane.com/v1/quotes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mail_class: 'certified_err',
to: ticket.customerAddress,
from: YOUR_RETURN_ADDRESS,
}),
}).then(r => r.json())
if (quote.error) {
await db.tickets.flagAddressProblem(ticket.id, quote.error.next)
return
}
// 3. Create the order and record your idempotency key BEFORE calling
// paperplane, so a crash between the call and the write can't
// leave you unable to tell whether it went out.
await db.mailOrders.reserve(ticket.id + ':formal-notice')
const order = await fetch('https://sendpaperplane.com/v1/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mail_class: 'certified_err',
text: renderNoticeText(ticket),
to: ticket.customerAddress,
from: YOUR_RETURN_ADDRESS,
email: COMPLIANCE_TEAM_EMAIL,
webhook_url: 'https://yourapp.com/hooks/paperplane-status',
}),
}).then(r => r.json())
// 4. Write the order back onto the ticket — this is your audit trail.
await db.mailOrders.attachOrder(ticket.id + ':formal-notice', order.id)
await ticket.addInternalNote(
`Certified notice queued — paperplane order ${order.id}. Payment: ${order.payment_url ?? 'sandbox (test run)'}`
)
}Why idempotency matters here more than in most integrations
Webhooks get retried. Queues redeliver. A deploy restarts mid-request. In most automations that is harmless — worst case a Slack message goes out twice. Here it is not harmless: POST /v1/orders is a write action that spends real money and puts a physical letter in the mail. A letter that has already been printed cannot be un-printed, and a certified notice that reaches someone twice can look like harassment or simply confuse the case record.
The fix does not live in the API — it lives in your backend, because only your backend knows what "the same event" means for your data. Pick an idempotency key that identifies the specific thing you are notifying about (a ticket ID plus a notice type is usually enough), check for an existing order under that key before calling paperplane, and record the key immediately once you get an order_id back — ideally in the same transaction or as close to it as your stack allows, so a crash between the API call and the write doesn't leave you unable to tell whether the letter already went out.
Why an audit trail matters for compliance mail specifically
The whole reason most of these use cases exist is to be able to answer, later, "did we notify this person, through what channel, and when did it arrive." That answer needs to live where the rest of the case history lives — on the ticket or the account record — not buried in a mail vendor's dashboard that whoever handles the eventual dispute doesn't have access to. Log at least three things back onto the record for every order you create: the order_id (your reference for looking it up again), the tracking number once USPS assigns one, and each status change as it happens — screening, printing, mailed, delivered.
The cheapest way to get those status changes is to pass webhook_url on the order and let paperplane push them to your backend as they occur, rather than polling GET /v1/orders/:id on a timer. Either works; push is less code to maintain.
// Receiving paperplane's status webhook and logging it back into the CRM
app.post('/hooks/paperplane-status', async (req, res) => {
const { order_id, status, tracking_number } = req.body
const ticketRef = await db.mailOrders.findByOrderId(order_id)
if (ticketRef) {
await crm.appendNote(ticketRef.ticketId, {
order_id,
status, // screening -> printing -> mailed -> delivered
tracking_number,
at: new Date().toISOString(),
})
if (status === 'delivered') {
await crm.closeComplianceTask(ticketRef.ticketId)
}
}
res.sendStatus(200)
})Before you go live
- Build and test the entire flow with
sandbox: true— quoting, ordering, and simulated status changes all run instantly and for free, so you can verify the trigger condition, the idempotency check, and the audit-trail write-back before a single real letter or dollar is involved. - Every address is checked against USPS data before anything is created; a bad address comes back as a structured error with a corrected candidate, which you can either apply automatically or route to a human for review.
- If your compliance process needs proof of delivery, not just proof of mailing, use
certified_err(Certified Mail + electronic Return Receipt) — the receipt itself is part of what a licensing board, regulator, or opposing counsel will typically ask to see.
This is a general pattern, not a product feature — there is no dedicated "compliance mail" endpoint, just the same /v1/quotes, /v1/uploads, /v1/orders, and /v1/orders/:id surface documented on /developers. If your tool of choice later gets a native integration, migrating off a direct REST integration is usually just swapping the HTTP call for the connector's action block — the order payload stays the same.
Related guides
Common questions
Is there a Salesforce, HubSpot, or Zendesk integration?
Not a pre-built one today — those, along with Zapier, n8n, Make, and Google Sheets, are covered on the integrations page. For everything else, the REST API described here is the general-purpose path: any tool that can make an outbound webhook call or run a scheduled job can trigger a letter.
What stops a retried webhook from mailing the same letter twice?
paperplane does not de-duplicate orders for you — an order is created the moment POST /v1/orders is called. Your backend needs its own idempotency key (a ticket ID plus notice type works well) that you check before calling the API and record right after. Once a letter is printed it cannot be recalled, so this check has to happen on your side, before the call, not after.
Why does this need an audit trail at all?
Compliance use cases usually exist because someone will ask "did we notify them, and when, and can you prove it." Logging the order_id, the tracking number, and each status change (screening, printing, mailed, delivered) back onto the ticket or case record turns that into a two-second lookup instead of a support escalation.
Does this require an API key or a paperplane account?
No — the API is keyless; each order carries its own payment (a Stripe payment_url for a human to approve, or a prepaid credit balance for hands-off sending). Use sandbox: true while building the integration to run the full flow — quote, order, simulated status changes — for free.
How do I get delivery status back without polling?
Pass webhook_url on the order and paperplane posts status changes (screening → printing → mailed → delivered, with the tracking number once assigned) to that URL as they happen. Polling GET /v1/orders/:id works too if you prefer pull over push.