Quickstart: quote → commit → refund
Before you start
You need a test key (sk_test_…). If you're a platform, you also need the ID of a merchant you've created (mer_…). Every example uses JSON over HTTPS against:
https://api.learndilo.com/v1
Rates, rules and citations in the examples show the response shape only. They aren't a statement of any state's law.
1. Authenticate
Send your key as a bearer token. With a platform key, add a Tax-Merchant header to act for one of your merchants, the way Stripe-Account works. Merchant keys don't need the header.
Authorization: Bearer sk_test_…
Tax-Merchant: mer_8Hq2… # platform keys only
Tax-Version: 2026-11-01 # optional: pin behaviour to a dated version
We show a key once, when it's created, and store only a hash. Roll a key with POST /v1/api_keys/{id}/roll; the old key keeps working for 24 hours to 7 days, as you choose. Each key has a checksum, so secret scanners can spot a leaked key.
2. Quote
POST /v1/quotes calculates tax and stores nothing. Quotes are free, so call it on every cart change or draft-invoice update.
curl https://api.learndilo.com/v1/quotes \
-H "Authorization: Bearer sk_test_…" \
-H "Tax-Merchant: mer_8Hq2…" \
-H "Content-Type: application/json" \
-d '{
"currency": "usd",
"tax_date": "2026-11-07",
"customer": "ext:cus_K39sWBu92Lo",
"addresses": {
"ship_from": { "line1": "500 W 2nd St", "city": "Austin", "state": "TX", "postal_code": "78701", "country": "US" },
"ship_to": { "line1": "1 Main St", "line2": "Ste 400", "city": "Columbus", "state": "OH", "postal_code": "43215", "country": "US" }
},
"lines": [
{ "id": "li_1", "product": "ext:prod_saas_pro", "quantity": 10, "amount": 50000, "discount_amount": 5000 },
{ "id": "li_2", "tax_code": "services.professional.implementation", "quantity": 1, "amount": 200000 }
]
}'
Response (trimmed):
{
"object": "quote",
"content_release_id": "rel_2026_11_03_01",
"calculation_id": "calc_9b1f…",
"address": { "confidence": "rooftop", "resolved": { "jurisdictions": ["jur_oh", "jur_oh_franklin", "jur_oh_cota"] } },
"amount_subtotal": 245000,
"amount_taxable": 45000,
"amount_tax": 3600,
"lines": [
{ "id": "li_1", "tax_code": "saas.general", "amount_taxable": 45000, "amount_tax": 3600,
"taxes": [
{ "jurisdiction": "jur_oh", "type": "state", "rate": "0.0575", "amount_tax": 2588,
"rule": "oh.saas.b2b_electronic_info_service", "citation": "ORC 5739.01(B)(3)(e)" },
{ "jurisdiction": "jur_oh_franklin", "type": "county", "rate": "0.0125", "amount_tax": 562 },
{ "jurisdiction": "jur_oh_cota", "type": "special", "rate": "0.0100", "amount_tax": 450 }
] },
{ "id": "li_2", "amount_taxable": 0, "amount_tax": 0, "reason": "not_taxable" }
],
"warnings": []
}
- Each line has either a
product, which maps to a tax code, or atax_codedirectly. Negative amounts are fine for prorations and credits. - By default (
collect_policy: "registered_only") we charge no tax where the merchant isn't registered, and report the would-be tax inuncollectedfor nexus planning. - A weak address never fails the call. You get the best match, a lower
confidenceand a warning. Setoptions.strict_address: trueif you'd rather get a422. - Rounding is per line, per jurisdiction, half-up to the cent, the same as Stripe.
3. Commit
POST /v1/transactions records the sale in the ledger once the invoice is finalized or the payment succeeds. The body is the quote body plus your own ID.
{
/* …same fields as the quote… */
"external_id": "in_1JSW342eZvKYlo2C",
"content_release_id": "rel_2026_11_03_01",
"collected": { "amount_tax": 3600 },
"source_refs": { "stripe": { "account": "acct_1Nv…", "invoice": "in_1JSW342eZvKYlo2C" } },
"metadata": { "order_id": "8812" }
}
external_idis your idempotency key. Retrying with the same ID and body returns the stored transaction (200,"idempotent_replay": true).content_release_idpins the tax content the quote used (for up to 7 days), so the committed tax equals what the customer saw.collectedrecords what you actually charged. If it differs from our calculation, we store both and flag the transaction. We never reject it.
The response is 201 with a transaction: the quote fields plus id, type: "sale", status: "committed", version, reporting_period and what's still refundable per line. Look it up by your ID any time: GET /v1/transactions/ext:in_1JSW342eZvKYlo2C.
4. Refund
POST /v1/transactions/{id}/refunds covers goods or service given back. We keep the original, so you don't resend line details.
curl https://api.learndilo.com/v1/transactions/ext:in_1JSW342eZvKYlo2C/refunds \
-H "Authorization: Bearer sk_test_…" -H "Tax-Merchant: mer_8Hq2…" \
-d '{
"external_id": "cn_1Kx…",
"refund_date": "2026-12-02",
"type": "partial",
"lines": [ { "line_id": "li_1", "quantity": 2 } ]
}'
type: "full"refunds whatever is still refundable."amount": 25000with no lines spreads the amount across lines in proportion.- Refund tax is prorated from the original's stored taxes, never recalculated at today's rates.
- The refund is a new negative transaction in the refund date's period, linked to the original.
tax_only: truerefunds just the tax, e.g. when a certificate arrives after the sale. - Void (
POST /v1/transactions/{id}/void) means the sale never happened. It's allowed only until the period is filed; after that it returns409 period_closed.
5. Correct a wrong calculation
If the tax was wrong (the address, tax code or certificate was wrong, or our content was), call POST /v1/transactions/{id}/corrections with only the fields that change. Don't use it for returns; those are refunds.
{
"external_id": "corr_123",
"reason": "wrong_jurisdiction",
"correction_date": "2026-12-02",
"corrected": { "addresses": { "ship_to": { /* fixed address */ } } },
"customer_action": { "execute_via": "none" }
}
We recalculate with the original date and content plus your fix. If the period is still open, the original is adjusted. If it's closed, we create a linked credit or charge in the period the state's rules require. The response tells you whether the customer is owed a refund or can be billed for the difference. With the Stripe connector, execute_via: "stripe" issues the credit note or invoice for you.
Conventions
| Money | Integer minor units (cents) with lowercase ISO currency. v1 accepts usd only. Rates are decimal strings ("0.0825"), never floats. |
|---|---|
| IDs | Prefixed and opaque: mer_, cus_, prd_, txn_, cert_, evt_, req_… Any path that takes an ID also takes yours as ext:<external_id>. |
| Dates | tax_date is a calendar date in the merchant's accounting time zone. Timestamps are RFC 3339 UTC. |
| Versions | /v1 changes only for a rewrite. Behaviour changes ship as dated versions (Tax-Version). Your account is pinned to the version current at signup. New fields and enum values don't count as breaking, so ignore fields you don't know. |
| Pagination | limit (≤ 100), starting_after, ending_before; lists return has_more. |
| Tracing | Every response has a Request-Id header. Quote it to support. |
Idempotency
There's no separate Idempotency-Key header. Your own ID (external_id) is the key for every create.
- Same ID, same body: you get the stored resource back, with no duplicate and no new event.
- Same ID, different body:
409 idempotency_conflict, naming the existing resource. To change it, useadjust. adjustandvoidtakeexpected_versionfor optimistic locking.
Errors and warnings
{ "error": {
"type": "conflict_error",
"code": "period_closed",
"message": "Filing period per_8f3 (TX, 2026-10) is closed. Create a refund instead.",
"request_id": "req_01JC…" } }
| HTTP | type | Example codes |
|---|---|---|
| 400 | invalid_request_error | parameter_missing, currency_unsupported |
| 401 / 403 | authentication_error / permission_error | api_key_invalid, merchant_not_accessible |
| 409 | conflict_error | idempotency_conflict, period_closed, refund_exceeds_original |
| 422 | calculation_error | missing_tax_code, address_unresolvable (strict mode) |
| 429 | rate_limit_error | rate_limited (with Retry-After), spend_cap_reached (commits only) |
| 5xx | api_error | Safe to retry any call that carries your ID |
Warnings never fail a request. They come back in warnings[] on a 200: address_low_confidence, nexus_not_registered, exemption_hint_without_certificate.
Webhooks
Events include transaction.committed, transaction.refunded, transaction.corrected, nexus.threshold_approaching, certificate.expiring, usage.anomaly and usage.cap_reached. Deliveries are signed:
Tax-Signature: t=1762560000,v1=<HMAC-SHA256(secret, t + "." + body)>
Delivery is at least once, so dedupe on the event id. Reject signatures older than 5 minutes. Platforms can opt into batches of up to 100 events. Failed deliveries retry for 3 days, and you can replay events for 30 days.
All resources
| Resource | Endpoints |
|---|---|
| Merchants | POST /merchants, GET /merchants/{id}, GET /merchants |
| Registrations | POST /registrations, GET /registrations |
| Customers | POST /customers (upsert by external_id), GET /customers/{id} |
| Certificates | POST /certificates, POST /certificates/{id}/revoke, GET /customers/{id}/exemptions |
| Products and tax codes | POST /products, GET /tax_codes |
| Addresses | POST /addresses/resolve |
| Quotes | POST /quotes |
| Transactions | POST /transactions, …/adjust, …/void, …/refunds, …/corrections, …/versions |
| Nexus and periods | GET /nexus, GET /filing_periods, POST /filing_periods/{id}/close |
| Reports | POST /report_runs (liability, transactions export, nexus detail, Stripe reconciliation) |
| Usage and caps | GET /usage, POST /spend_caps |
| Webhooks | POST /webhook_endpoints, GET /events, POST /events/{id}/redeliver |
| Imports | POST /imports (source: stripe) |
| API keys | POST /api_keys, POST /api_keys/{id}/roll, DELETE /api_keys/{id} |