Classify Your Own Transactions
When your application already has access to users’ transaction data, Sail can help identify which transactions qualify as tax-deductible healthcare expenses under IRS Section 213(d).
In this guide, you’ll create an external connection, push a batch of transactions, and read back the classification results.
Requirements
Before you start, make sure you have:
- A Sail user created for your internal user
- An API key with the
expensesandingestscopes - A webhook endpoint configured to receive events
Step 1: Create an external connection
Before you can push transactions, you need to Create an External Connection to group the transaction data and label its source. Set type to external and choose a provider_label that identifies where this data comes from (for example, “Plaid feed” or “Internal ledger”).
curl -X POST https://live.savewithsail.com/api/v1/users/usr_abc123/connections \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"type": "external",
"provider_label": "Acme Budgeting · Plaid feed"
}'{
"id": "conn_9f2c",
"user_id": "usr_abc123",
"type": "external",
"status": "active",
"products": ["expenses"],
"accounts": [],
"created_at": "2026-08-10T14:32:00Z"
}The response confirms the connection was created. Save the id (conn_9f2c in this example). You need it to push transactions in the next step. The products array shows that this connection supports expenses, which means Sail will classify the transactions you push to it.
Step 2: Push transactions
Send a batch of transactions to the external connection. Sail accepts up to 500 transactions per request. Each transaction needs an external_transaction_id that’s unique within this connection. If you push the same external_transaction_id again, Sail updates the existing record and re-classifies it.
curl -X POST https://live.savewithsail.com/api/v1/users/usr_abc123/connections/conn_9f2c/transactions \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"transactions": [
{
"external_transaction_id": "tx-1001",
"date": "2026-08-05",
"amount": 27.02,
"description": "WALGREENS #4821 RX",
"merchant_name": "Walgreens",
"mcc": "5912"
},
{
"external_transaction_id": "tx-1002",
"date": "2026-08-06",
"amount": 85.00,
"description": "AMAZON.COM*AB1CD2EF3",
"merchant_name": "Amazon",
"mcc": null
}
]
}'{
"status": "processing",
"job_id": "job_x",
"accepted": 2,
"duplicates_updated": 0
}The response returns 202 with a job_id. Classification happens asynchronously. Don’t poll for results. Instead, listen for the transactions.ingested webhook.
Listen for the webhook
After you push a batch, Sail enriches and classifies each transaction. When the batch is done, Sail sends a transactions.ingested webhook:
{
"id": "evt_7f3k9m",
"event": "transactions.ingested",
"created_at": "2026-08-10T14:33:15Z",
"user_id": "usr_abc123",
"data": {
"connection_id": "conn_9f2c",
"job_id": "job_x",
"created": 2,
"updated": 0
}
}The created and updated counts show how many expenses were new versus re-classified from a duplicate push.
You will receive an expenses.verified event when 213d classification has finished for these expenses.
Step 3: Pull classification results
Once you receive the webhook, fetch the expenses to see how Sail classified each transaction.
curl https://live.savewithsail.com/api/v1/users/usr_abc123/expenses \
-H "Authorization: Bearer sk_live_..."{
"data": [
{
"id": "exp_9f2c",
"connection_id": "conn_9f2c",
"external_transaction_id": "tx-1001",
"name": "Walgreens",
"amount": 27.02,
"status": "eligible",
"source_type": "external",
"origin": {
"type": "transaction",
"detail": "WALGREENS #4821 RX"
},
"merchant": {
"name": "Walgreens",
"supported_merchant_id": "mer_walgreens"
},
"enrichment": {
"category": "Pharmacies",
"subcategory": "Drug Stores"
},
"section_213d_classification": {
"status": "eligible",
"category": "Prescription Medications",
"reasoning": "Pharmacy purchase classified as eligible under Section 213(d)."
}
}
// ... additional expenses
],
"pagination": {
"limit": 25,
"offset": 0,
"total": 2
}
}You can filter the results by status, connection, or source type to get specific subsets:
# Get only eligible expenses
curl "https://live.savewithsail.com/api/v1/users/usr_abc123/expenses?status=eligible" \
-H "Authorization: Bearer sk_live_..."
# Get only expenses from a specific connection
curl "https://live.savewithsail.com/api/v1/users/usr_abc123/expenses?connection_id=conn_9f2c" \
-H "Authorization: Bearer sk_live_..."The section_213d_classification.status field tells you the outcome for each transaction:
| Status | What it means | What to do |
|---|---|---|
eligible | The expense qualifies as tax-deductible under Section 213(d). | No action needed. |
ineligible | The expense does not qualify. | No action needed. |
itemization_required | Sail can’t classify the transaction without seeing individual items (for example, a mixed-basket Amazon order). | Prompt the user to connect the merchant via a connect widget session for item-level data, or upload a receipt via the receipt upload endpoint. |
lmn_required | The item could be eligible with a Letter of Medical Necessity (for example, a massage chair). | Prompt the user to provide a letter from their healthcare provider. |
The top-level status field groups these into three categories: eligible, ineligible, and needs_review (which covers both itemization_required and lmn_required).
Note
Check the merchant.supported_merchant_id field in the expense response. When it is non-null for a needs_review expense, Sail supports a store connection for that merchant. Prompt the user to connect their merchant account through the Connect widget to retrieve item-level data and classify individual items. See Enrich transactions with item-level data for details.