API & integrations
A handwritten card, the moment it matters.
When a deal closes, a client signs or a donation comes in, your tools can send a card on their own: written individually for that person from your template, then handwritten and mailed. New integrations start in approval mode, so you see every card before it’s paid for.
Quick start
- Set up your business profile (name, signer, return address) in the dashboard.
- Create an API key under Integrations. Pick its permissions and whether its cards wait for approval (the default) or send on their own.
- POST a send. The card is written within a minute and appears in “Approve these”.
curl -X POST https://sohandled.com/api/v1/sends \
-H "Authorization: Bearer bt_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: deal-4821-thank-you" \
-d '{"recipient":{"name":"Maria Lopez","address_line1":"14 Elm Street","address_line2":"Apt 2","city":"Austin","state":"TX","postal_code":"78701","country":"US"},"template":"client-thank-you","merge_fields":{"property_address":"14 Elm Street","closing_date":"2026-06-30"},"arrive_by":"2026-11-20","format":"card","external_id":"deal-4821","schedule_anniversary":true}'Authenticate with Authorization: Bearer bt_live_… (or X-Api-Key). Keys are stored hashed, shown once, scoped (sends:write, sends:read), and show when they were last used. Revoke one any time.
Create a send
POST https://sohandled.com/api/v1/sends. Send the same Idempotency-Key header (or idempotency_key) when you retry: you get the same send back, never a second card. Without one, external_id (or the exact body) does the same job.
{
"recipient": {
"name": "Maria Lopez",
"address_line1": "14 Elm Street",
"address_line2": "Apt 2",
"city": "Austin",
"state": "TX",
"postal_code": "78701",
"country": "US"
},
"template": "client-thank-you",
"merge_fields": {
"property_address": "14 Elm Street",
"closing_date": "2026-06-30"
},
"arrive_by": "2026-11-20",
"format": "card",
"external_id": "deal-4821",
"schedule_anniversary": true
}- recipient
- name (first and last), address_line1, address_line2, city, state, postal_code, country (default US). Or first_name + last_name.
- template
- A saved template’s id (number), or a built-in: client-thank-you, referral-thank-you, home-anniversary, client-birthday, holiday-client, thanksgiving-gratitude, checking-in, staff-thank-you, donor-first-gift, donor-repeat-gift, donor-major-gift, guest-thank-you, guest-welcome, guest-stay-anniversary, guest-repeat-thank-you, customer-note, customer-holiday-new-year, customer-holiday-christmas, customer-holiday-happy-holidays, customer-holiday-thanksgiving, customer-holiday-valentines, customer-holiday-fourth-of-july. Leave it out to use your first saved template.
- merge_fields
- Values for the template’s {fields}, up to 30.
- arrive_by
- Optional YYYY-MM-DD. We work back to the mailing date (an estimate); too soon becomes “as soon as possible”.
- format
- "card" (folded, default), "flat" or "letter" (a full page).
- external_id
- Your record id, returned with the status and usable to look sends up.
- assign_to
- Optional: the email of someone on your team. The card is theirs: signed with their name, listed in their Cards, and theirs to approve when it waits. Left out, the card is the Owner’s (or, for a client in your client book, that client’s person’s). An email that isn’t on your team is refused (422). Webhooks: map a field to “Assign to”; no match sends it as the Owner’s.
- schedule_anniversary
- true + closing_date: also schedules the 1-year home anniversary card, arriving by that date’s anniversary.
- gift
- Nonprofits: amount, date, designation, donor_type ("first" or "repeat"), transaction_id. Picks the gift template.
Merge fields: {first_name}, {last_name}, {full_name}, {property_address}, {city}, {home_years}, {company}, {signer}, {closing_date}, {amount}, {designation}, {gift_date}. Each card is still written individually by our writer from your template, in its tone; merge fields are facts it uses, not a mail merge.
{
"id": 1042,
"status": "received",
"hold_reason": null,
"template": "client-thank-you",
"template_name": "Client thank-you",
"format": "folded-portrait",
"external_id": "deal-4821",
"arrive_by": "2026-11-20",
"scheduled_for": null,
"price_cents": null,
"order_id": null,
"mailed_at": null,
"estimated_arrival": null,
"error": null,
"created_at": "2026-10-10T18:04:11.000Z",
"replayed": false,
"anniversary": {
"id": 1043,
"status": "scheduled",
"arrive_by": "2027-06-30",
"scheduled_for": "2027-06-09"
}
}Also: GET /api/v1/sends?external_id=… (recent sends, newest first) and GET /api/v1/templates (your saved templates and the built-ins).
Status
GET https://sohandled.com/api/v1/sends/<id> with a key that has sends:read.
- scheduled
- A home anniversary card waiting for its day (writing starts 3 weeks before it should arrive).
- received
- Stored; the writer starts within a minute.
- writing
- The card is being written for this recipient.
- awaiting_approval
- Waiting in “Approve these” (approval mode, or over this month’s spend cap).
- awaiting_payment
- Approved, waiting for payment (no saved-card billing yet, no card on file, or a declined card).
- approved
- Approved and being charged (monthly billing charges once a day).
- paid
- Paid; queued for the pen (or waiting for its mailing date).
- being_written
- Being handwritten by Mr. Handled.
- mailed
- Stamped and mailed; mailed_at and estimated_arrival are set.
- rejected
- You removed it from the queue.
- canceled
- Canceled and refunded before mailing.
- failed
- Couldn’t be written (see error) or couldn’t be sent.
Prices, billing and the spend cap
1–9 per charge
$5.95
a card · $7.95 a letter, stamp included
10–24 per charge
$5.45
a card · $7.45 a letter, stamp included
25+ per charge
$5.10
a card · $7.30 a letter, stamp included
- Per request (default): one card on its own is the single price; approving many at once (“Approve all”) is one charge at that group’s tier.
- Monthly billing (opt in): your cards this calendar month count toward the 10–24 and 25+ tiers. Auto-sent cards are charged together once a day. Each charge carries its own card-processing fee, so a charge of only a card or two may be priced at a smaller tier.
- Spend cap: $500 a month by default, editable. Your dashboard shows this month’s spend against it, and cards beyond the cap wait for your approval.
- Paying: with saved-card billing on, approved cards are charged to your saved card. Until then (or without a card), they wait in “Approve these” and you pay for any number of them with one tap on a secure Stripe page.
Zapier: Webhooks by Zapier
1
Make an API key
In your dashboard, Integrations → API keys → New key. Copy it: it’s shown once.
2
Create a Zap
Pick the trigger (a deal closing, a new client, a date). Add a Filter step if only some should get a card.
3
Add “Webhooks by Zapier” → POST
URL: the sends endpoint below. Payload type: json. Add the Headers Authorization (Bearer + your key) and Idempotency-Key.
4
Map the fields
In Data, add recipient__name, recipient__address_line1, recipient__city, recipient__state, recipient__postal_code, template and merge_fields__… (Zapier turns __ into nested JSON).
5
Test, then turn it on
The test card lands in “Approve these” (approval mode is on for new keys), so nothing is charged until you approve it.
URL https://sohandled.com/api/v1/sends
Payload type json
Headers Authorization: Bearer bt_live_… and Idempotency-Key: {{Deal ID}}
Prefer no key in Zapier? Use your webhook URL from the dashboard instead and map the fields there (below).
Deal closed → client thank-you (and the home anniversary next year)
Trigger: Your CRM’s “Deal stage changed” or “Deal won” trigger (filter: stage is Closed).
{
"recipient": {
"name": "{{Contact Name}}",
"address_line1": "{{Mailing Street}}",
"city": "{{Mailing City}}",
"state": "{{Mailing State}}",
"postal_code": "{{Mailing Zip}}"
},
"template": "client-thank-you",
"merge_fields": {
"property_address": "{{Property Address}}",
"closing_date": "{{Close Date}}"
},
"external_id": "deal-{{Deal ID}}",
"schedule_anniversary": true
}New client → welcome card
Trigger: “New contact” in your CRM, or “New row” in a Google Sheet of clients.
{
"recipient": {
"name": "{{Full Name}}",
"address_line1": "{{Street}}",
"city": "{{City}}",
"state": "{{State}}",
"postal_code": "{{Zip}}"
},
"template": "client-thank-you",
"merge_fields": {
"company": "{{Your Company}}"
},
"external_id": "client-{{Contact ID}}"
}Client anniversary → card that arrives on the day
Trigger: “Schedule by Zapier” (every day) + a lookup of clients whose anniversary is 3 weeks away, or your CRM’s date-based trigger.
{
"recipient": {
"name": "{{Client Name}}",
"address_line1": "{{Street}}",
"city": "{{City}}",
"state": "{{State}}",
"postal_code": "{{Zip}}"
},
"template": "home-anniversary",
"merge_fields": {
"home_years": "{{Years}} years",
"property_address": "{{Property Address}}"
},
"arrive_by": "{{Anniversary Date}}",
"external_id": "anniversary-{{Client ID}}-{{Year}}"
}Your inbound webhook URL
Every business can make webhook URLs (Integrations → New webhook). The secret is in the URL. Send JSON, a form post, or a GET with query parameters from any tool. The first request is saved as a sample: pick which of its fields is the name, street, city, state and ZIP (and any merge fields), optionally only send when a field equals a value (stage = Closed), and choose the template.
Optional HMAC: turn on “Require a signature” and send X-BT-Signature: sha256=<hex>, the HMAC-SHA256 of the raw body with the webhook’s secret.
// Node.js: sign the exact body you send.
import { createHmac } from 'node:crypto'
const body = JSON.stringify(payload)
const signature = createHmac('sha256', process.env.BT_WEBHOOK_SECRET).update(body).digest('hex')
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-BT-Signature': `sha256=${signature}` },
body,
})Real estate CRMs
Follow Up Boss
- In Follow Up Boss: Admin → API → copy your API key (the account owner’s key).
- In your dashboard: Integrations → Follow Up Boss → paste it.
- Pick which stages count as closed (for example “Closed”) and the template each sends. Deal pipeline stages work too.
- Leave “Home anniversary” on to schedule the 1-year card, arriving by the closing date’s anniversary (a “Closing date” custom field if you have one, else the deal’s close date or the day it closed).
We listen for Follow Up Boss’s stage-change webhooks, or check for stage changes every hour. People who were already closed when you connect don’t get a card. The card goes to the person’s home or mailing address in Follow Up Boss.
BoldTrail (kvCORE)
BoldTrail sends webhooks from Smart Campaigns as a GET with query parameters. Make a webhook URL in your dashboard, then in BoldTrail: Templates → add a Webhook, paste the URL, and add query parameters (name, street, city, state, zip) using BoldTrail’s variables. Add the webhook as an action in the Smart Campaign you run when a deal closes. Your first request becomes the sample to map.
?name={lead_name}&street={seller_address}&city={seller_city}&state={seller_state}&zip={seller_zip}For deal or transaction triggers BoldTrail doesn’t send itself, use the Zapier route above.
Any other CRM (Lofty, Sierra, Salesforce, HubSpot…): use its webhook with your webhook URL, or Zapier.
Donations
Nonprofits can send a thank-you for every new donation from their donation form or Zapier: include gift and we pick the first-gift, repeat-gift or major-gift template. Each gift is remembered, so the same donation is never thanked twice, and a later CSV upload of it is skipped.
{
"recipient": {
"name": "Dana Whitfield",
"address_line1": "220 Oak Avenue",
"city": "Columbus",
"state": "OH",
"postal_code": "43215"
},
"gift": {
"amount": "150.00",
"date": "2026-10-03",
"designation": "Summer reading program",
"donor_type": "first",
"transaction_id": "ch_3Q8x"
},
"idempotency_key": "donation-ch_3Q8x"
}Errors and limits
- 201 / 200
- Created / the same send again (a retry with the same idempotency key; "replayed": true).
- 401 / 403
- Missing or revoked key / the key lacks the scope.
- 409
- The idempotency key was used for a different body, or the business profile isn’t set up.
- 422
- A field needs fixing: "errors" names each one, like "recipient.postal_code".
- 429
- More than 300 sends or 900 reads per key per 15 minutes.
Every key, webhook, approval and charge is in your dashboard’s activity log.
Cards that send themselves.
Start in approval mode: nothing is charged until you approve a card.