Developer docs

Quickstart: quote → commit → refund

Preview. The Dilo API is in design with our first design partners. Endpoints and fields on this page follow the current spec and may change before general availability. Design partners get test keys and a sandbox base URL from us directly. Request access.

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": []
}

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" }
}

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 } ]
  }'

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

MoneyInteger minor units (cents) with lowercase ISO currency. v1 accepts usd only. Rates are decimal strings ("0.0825"), never floats.
IDsPrefixed and opaque: mer_, cus_, prd_, txn_, cert_, evt_, req_… Any path that takes an ID also takes yours as ext:<external_id>.
Datestax_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.
Paginationlimit (≤ 100), starting_after, ending_before; lists return has_more.
TracingEvery 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.

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…" } }
HTTPtypeExample codes
400invalid_request_errorparameter_missing, currency_unsupported
401 / 403authentication_error / permission_errorapi_key_invalid, merchant_not_accessible
409conflict_erroridempotency_conflict, period_closed, refund_exceeds_original
422calculation_errormissing_tax_code, address_unresolvable (strict mode)
429rate_limit_errorrate_limited (with Retry-After), spend_cap_reached (commits only)
5xxapi_errorSafe 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

ResourceEndpoints
MerchantsPOST /merchants, GET /merchants/{id}, GET /merchants
RegistrationsPOST /registrations, GET /registrations
CustomersPOST /customers (upsert by external_id), GET /customers/{id}
CertificatesPOST /certificates, POST /certificates/{id}/revoke, GET /customers/{id}/exemptions
Products and tax codesPOST /products, GET /tax_codes
AddressesPOST /addresses/resolve
QuotesPOST /quotes
TransactionsPOST /transactions, …/adjust, …/void, …/refunds, …/corrections, …/versions
Nexus and periodsGET /nexus, GET /filing_periods, POST /filing_periods/{id}/close
ReportsPOST /report_runs (liability, transactions export, nexus detail, Stripe reconciliation)
Usage and capsGET /usage, POST /spend_caps
WebhooksPOST /webhook_endpoints, GET /events, POST /events/{id}/redeliver
ImportsPOST /imports (source: stripe)
API keysPOST /api_keys, POST /api_keys/{id}/roll, DELETE /api_keys/{id}