API changelog

Every change an integration can notice, newest first. Deprecations name the endpoints they affect, what to use instead and the date the old behaviour may stop — never sooner than 90 days after it is announced here. How versioning works.

Machine-readable: deprecated endpoints answer with Deprecation, Sunset and Link: <…>; rel="deprecation" headers.

· added · #

GET /api/v1/merchant/connections: a connection's mode can be "sample"

Besides live and test, mode can now be sample: sample activity written into a review or demo business (the Shopify App Store review business carries a sample Shopify store whose payouts are matched to sample bank deposits). A sample connection has no account at the processor behind it and never contacts one; its lines, payouts and webhooks (payout.matched) look exactly like a real one's. Customers cannot create one. Treat it like test data if your integration should ignore it.

· added · #

GET /health reports whether the scheduled jobs are running

The monitoring endpoint GET /health (outside /api/v1; its envelope is unchanged) gains checks.scheduled_jobs: status (ok, error or warning), message, checked (how many jobs the server expects), stale (the names of jobs whose last successful run is older than three times their interval), stale_detail and enforced. On production a stale job answers 503 in the error envelope with the same checks under error.details, as a failed database check already did; other environments report it at 200. data.status is degraded while any check is not ok. GET /api/v1/health is unchanged.

· added · #

AI endpoints answer 503 AI_BUDGET_EXHAUSTED when the deployment's AI spend cap is reached

Every OpenAI call the service makes is now metered, and the deployment can cap its monthly AI spend (and a business's daily allowance on the chat). When a cap is reached, POST /api/v1/chat, POST /api/v1/reports/narrative, POST /api/v1/reports/variance/summary and the receipt OCR endpoints answer 503 with error.code AI_BUDGET_EXHAUSTED and error.details.scope of "month" or "day", on the paths that already answered 503 *_NOT_CONFIGURED without an OpenAI key. Chat messages that are a draft intent still answer 200 with data.ai = "off". Treat it like CHAT_NOT_CONFIGURED: try again next day or next month, or without AI. Nothing else changes.

· added · #

Receipts are matched to their bank line, and the API can list them and undo a match

A receipt uploaded with POST /api/v1/receipts/upload is now read and filed in the receipts review queue like an emailed one; the response's receipt object gains inbox_item_id and match. When a receipt's amount, date and vendor match exactly one bank line it is attached to that line automatically (owners can turn this off). New: GET /api/v1/receipts and GET /api/v1/receipts/{id} (read scope) return each receipt with match.state (matched, suggested, waiting, reading, needs_review, dismissed), match.matched_by (auto or person) and the bank line; POST /api/v1/receipts/{id}/detach (write scope, owner or bookkeeper) undoes a match and answers 409 NOT_ATTACHED when there is none. POST /api/v1/receipts/{id}/extract and /confirm add data.match. A byte-identical second upload is filed as dismissed; automatic reading of phone receipts is capped at 200 per business per day (over it, match.state is needs_review). Attaching never posts anything to the ledger.

· added · #

POST /api/v1/auth/signup answers 202 'check your email' for every address when you send Prefer: signup=neutral

Send the request header Prefer: signup=neutral and signup (and /auth/register) answers 202 with data {status: check_email, email, email_verification_required, message} whether or not the address already has an account, plus Preference-Applied: signup=neutral. The new account's owner verifies the address and then signs in with POST /api/v1/auth/login; no user_id or entity_id is returned. The owner of an address that already has an account is emailed instead (at most once a day). 400, 422 and 429 answers are unchanged.

· deprecated · #

Signup's 409 EMAIL_ALREADY_REGISTERED and its user_id/entity_id are retiring

A signup without Prefer: signup=neutral still answers 201 with user_id and entity_id, or 409 EMAIL_ALREADY_REGISTERED for an address that has an account, which tells anyone whether an address is registered. After the sunset the neutral 202 becomes the only answer. Send Prefer: signup=neutral now; those requests carry no deprecation headers.

Affects:

  • POST /api/v1/auth/signup
  • POST /api/v1/auth/register

Use instead: Send Prefer: signup=neutral and treat 202 as 'check your email'

Sunset: May 4, 2027

· added · #

New webhook events invoice.payment_reversed and bill.payment_reversed

Reversing a payment (the Reverse button, POST /api/v1/invoices/{id}/payments/{paymentId}/void, POST /api/v1/bills/{id}/payments/{paymentId}/void) now emits one event per reversed payment, including part payments that never produced invoice.paid or bill.paid. data carries invoice_id or bill_id, payment_id, payment_date, amount (+ amount_cents), transaction_id (the payment's journal), reversal_transaction_id, released_bank_transaction_ids, reason, and the invoice or bill snapshot after the reversal; the bill snapshot of bill.payment_reversed adds paid_amount and balance_due (+ _cents). The id is evt_<event>_<document>_p<payment>. Not emitted when Smart Match takes back a payment it recorded a moment earlier because the bank line was posted elsewhere at the same time: that payment was never announced with invoice.paid or bill.paid. Endpoints subscribed to all events (an empty event_types list) start receiving these; subscribe explicitly to keep the old set.

· added · #

Invoice branding over the API: GET/PUT /api/v1/entities/branding and GET/POST/DELETE /api/v1/entities/branding/logo

Owner only. Read with the read scope, change with write. PUT takes business_email, business_phone and business_address (null or an empty string clears one; entity_id is accepted and ignored). The logo is a PNG or JPEG of at most 1 MB and 4000 px, sent as the multipart field logo. Connected apps (OAuth tokens) can read branding but get 403 NOT_AVAILABLE_TO_APPS on the three writes, because the details are what every invoice and statement tells customers; API keys and sign-in tokens can make them.

· added · #

Customer statements over the API: GET /api/v1/contacts/{id}/statement, /statement/pdf and POST /statement/send

Reads for owner, bookkeeper and accountant; send for owner and bookkeeper. as_of (YYYY-MM-DD) defaults to the business's today. Money comes as a decimal string with an integer _cents beside it. Send emails the PDF to the contact's own address only, at most 5 times a day per contact (429 with Retry-After). Connected apps (OAuth tokens) can read and download statements but get 403 NOT_AVAILABLE_TO_APPS on send, which emails the business's customer; API keys and sign-in tokens can send.

· changed · #

The PDF named by pdf.relative_path in invoice send is now deleted after 30 days

POST /api/v1/invoices/send/{invoiceId} (and /invoices/{invoiceId}/send) still returns pdf.filename and pdf.relative_path, and now pdf.retained_until (YYYY-MM-DD, UTC). The file is a server-side record, never a download link; it is deleted after retained_until. The emailed copy is the customer's.

· added · #

POST /api/v1/bills/{id}/pay takes expected_outstanding and refuses a stale payment with 409 BALANCE_CHANGED

Send expected_outstanding (the balance you showed the person) with a part payment. When the bill's balance no longer matches it (another payment or reversal landed, or this is a second copy of a request that already went through) the answer is 409 BALANCE_CHANGED with error.details.outstanding and nothing is paid. Without the field nothing changes.

· added · #

GET /api/v1/reports/variance and /api/v1/reports/variance/summary explain what moved a report row

GET /api/v1/reports/variance takes a report (income_statement, balance_sheet, cash_flow, ar_aging, ap_aging), a row (account:{id}, a total, retained_earnings, cash_net_change, cash_account:{id}, total_outstanding, bucket:{name}) and its dates, and returns the contributors to that row's change against the previous period or the same period last year: accounts, then payees or the other account, then the entries (on aging: customers or vendors, then invoices or bills), in integer cents that add up exactly to the change, each with a citation id and the entry or document ids, plus a plain headline_detail naming new payees and yearly charges. POST /api/v1/reports/variance/summary starts a plain-English summary written only from those contributors and returns a token; poll GET /api/v1/reports/variance/summary with that token until status is ready (retry=true on the POST regenerates a failed one). Every figure and name in a summary is inserted from the books, and each sentence lists the contributor and entry ids it cites. Read scope; the business is the caller's active entity.

· fixed · #

POST /api/v1/reports/narrative no longer reads a report cut off mid-row

A report larger than about 12 KB (a long chart of accounts, an AR aging with many open invoices) was cut at a fixed byte count before the narrative was written, so the summary described part of a list. The smallest rows of the longest lists are now left out instead, with a count of how many, and every total still covers the whole report. The response shape is unchanged; narratives cached before this change are written again on the next request.

· fixed · #

POST /api/v1/reports/explainability no longer reads "rate" or "sales" inside other words

Suggestions were chosen by substring, so any metric whose name contained "rate" (interest_rate, corporate_card, generated_revenue) was treated as a conversion metric and a small drop produced "Review conversion funnel", and sales_tax_payable or cost_of_sales falling produced "Investigate revenue decline". Metric names are now read as words: rate counts only as conversion_rate, close_rate and the like, a revenue word beside tax, payable, cost or returns is not revenue, and cost wins over revenue (cost_of_sales rising suggests "Optimise rising costs"). The response shape is unchanged.

· added · #

Bank deposits can be applied to several invoices at once, net of a processing fee

Review Bank Transactions now proposes the open invoices a deposit pays (one invoice, up to four from the same customer, or an invoice paid net of a card or processing fee) and records them with one click. Such a deposit is one ledger entry: a debit to the bank account for the deposit, a debit to the processing-fee account for any difference, and one Accounts Receivable credit per invoice, with one invoice payment per invoice (payment_method bank_transfer). invoice.paid fires for each invoice it settles, with the usual payload. POST /api/v1/invoices/{id}/payments/{paymentId}/void refuses a payment that came in with other invoices with 409 PAYMENT_IN_DEPOSIT_MATCH (undo the deposit match instead), and reverses a payment that came in alone together with its fee line. Nothing else changed.

· added · #

POST /api/v1/chat drafts invoices, bills, transfers and rules; drafts work without AI

The chat endpoint now recognises four more draft intents (create_invoice, create_bill, record_transfer, create_rule). Their draft_transaction carries the parsed counterparty, accounts, rule text and due date, ids resolved among your business's own contacts and accounts (null when not found, with a note), and form_path, the prefilled web form. Nothing is posted or saved by chat. record_expense drafts are unchanged. When the server has no AI configured, a message that is one of the five draft intents now answers 200 with data.ai = "off" and its draft instead of 503 CHAT_NOT_CONFIGURED; every other message still answers 503. New fields are additive; clients that ignore draft_transaction need no change.

· fixed · #

POST /api/v1/invoices/{id}/send-reminder attaches the invoice PDF and judges overdue on the business calendar

The reminder email now attaches the invoice PDF and ends with the business details from Entity Settings > Invoice branding, like the invoice email. Whether an invoice is overdue (400 INVOICE_NOT_OVERDUE) and the days_overdue in the response are now counted on the business's own calendar (its Entity Settings timezone), the same clock the daily reminder run uses, so an invoice due today where the business is can no longer be reminded early. The endpoint always sends the friendly reminder; firm reminders and final notices need an owner's approval of their exact text in the web app and are not sent over the API. The request and response are unchanged.

· added · #

Automatic posting: GET /api/v1/auto-posted, POST /api/v1/auto-posted/{id}/undo, and transactions.posted_automatically

A business owner can now turn on automatic posting: bank lines matching a rule marked to post automatically, or AI suggestions above the cutoff the owner saved, are posted without a click (within an amount cap, only to income and expense accounts, never in a locked period, never for a payee nobody has posted before, and never for a line dated more than 7 days before it arrived or before the setting was saved, so a first bank sync or an imported statement does not post itself). Such entries carry posted_automatically = 1 and, for rules, posted_by_rule_id on GET /api/v1/transactions. GET /api/v1/auto-posted lists them with their undo state; POST /api/v1/auto-posted/{id}/undo (owner or bookkeeper, write scope) reverses one within 7 days with a reversing entry dated on the original entry's own date, and returns its bank line to the review queue, where its suggestion is pending again (409 MERCHANT_RECONCILED or BANK_LINE_MOVED when the line has moved on). Voiding a bank posting on its transaction page now also returns its bank line to the review queue. Integrations that count review-queue items should expect fewer of them for businesses that use this.

· added · #

GET /api/v1/month-end-close/checks: what still blocks closing a month

New read-scope endpoint. For ?month=YYYY-MM it returns the computed close checks (bank reconciliation, unposted and unmapped bank lines, the review queue, sent-but-not-finalized invoices, draft bills, trial balance, suspense and Opening Balance Equity, unmatched payouts and inbox receipts) with counts, amounts as decimal strings, the web page that fixes each and up to five specific items, the manual review items, a ranked next_actions list, the close record (whether an owner closed the month with items open; the note itself is not exposed) and an advisory comparison with the three months before. Items dated in a closed period are reported as carried_count and never block. Closing a month is still done on the web; nothing about existing endpoints changed.

· changed · #

Review-queue suggestions now appear after every bank import, with the AI's own confidence

After a Plaid sync, CSV or statement import or a merchant feed, rules run and then, when the business has AI suggestions on, every line no rule matched is given an AI suggestion in the background within a few minutes. GET /api/v1/review-queue/pending-ai-suggestions lists them as before; nothing calls a run endpoint first. For AI suggestions made this way, confidence is the model's own score between 0 and 1 instead of the fixed midpoint of its level (confidence_level is unchanged). AI suggestions never name an Accounts Receivable, Accounts Payable, Undeposited Funds, Opening Balance Equity or Retained Earnings account. POST /api/v1/review-queue/{suggestionId}/approve now answers 409 IGNORED for a suggestion whose bank line was ignored, instead of posting it. Nothing is posted until a suggestion is approved.

· fixed · #

invoice.created fires for recurring and duplicated invoices; invoice.paid and bill.paid fire for Smart Match payments

Invoices made by a recurring schedule (Make recurring) or by Duplicate as a new draft now emit invoice.created like every other creation path. A payment recorded by confirming a Smart Match that settles an invoice or bill now emits invoice.paid or bill.paid, as the web forms and the API already did. Subscribers that count on those events no longer miss these documents; nothing else about the payloads changed.

· security · #

POST /api/v1/auth/signup and /auth/register allow 5 attempts per IP per 15 minutes

Every signup attempt that passes validation now counts against a per-IP budget of 5 per 15 minutes, the same cap the web signup form has. Past it the answer is 429 RATE_LIMIT_EXCEEDED with Retry-After. The budget is spent before the address is looked up, so the documented 409 EMAIL_ALREADY_REGISTERED can no longer be used to test addresses for accounts at the general API rate. The owner of an address that already has an account is also emailed (at most once a day), as the web form does. Responses are otherwise unchanged. An integration that creates many accounts from one server should space them out.

· fixed · #

GET /api/v1/invitations reports expired invitations; bill match-withdrawal refuses a deposit

A pending invitation whose expires_at has passed is now listed with status expired, status=pending leaves it out, and status=expired (documented, but answered 400) lists only those. POST /api/v1/bills/payments/match-withdrawal answers 422 NOT_A_WITHDRAWAL for a bank row that is money in, as the invoice side already did for money out (NOT_A_DEPOSIT). POST /api/v1/invoices/record-payment also refuses a payment date inside a locked general-ledger period (409 PERIOD_LOCKED), not only a locked receivables period. Sign-ins over POST /api/v1/auth/login and /auth/mfa/verify now send the same new-device email the web sign-in sends.

· changed · #

Archived businesses are left out of GET /api/v1/entities and are never the default entity

An owner can now archive a business on the web (Business Settings > Archive this business); its books are kept and it can be restored from My Businesses. While archived it is not listed by GET /api/v1/entities, POST /api/v1/entities/switch refuses it (403 FORBIDDEN, as for a non-membership), and requests that name no entity act on another business. A request that names an archived business by entity_id still reaches it, so nothing an integration stored stops resolving. Nothing changes for a business that is not archived.

· fixed · #

Approving a review-queue suggestion refuses two postings that could never be corrected

POST /api/v1/review-queue/{suggestionId}/approve now answers 422 INVALID_OFFSET when the offset account (stored or sent as offset_account_id / category) is the bank account itself, which used to post a journal that cancelled itself and mark the bank line posted. It answers 422 HOLDING_ROW_MAPPED when the bank line is still on the Holding account although its bank account has since been mapped: move it off Holding first, then approve. Bulk approve (POST /api/v1/ai/review/bulk-approve) lists such suggestions under skipped with the same codes. Nothing else about approve changed.

· fixed · #

A custom-feed delivery whose lines could not be stored is no longer acknowledged as a duplicate on retry

POST /api/v1/merchant/custom/webhook used to record a delivery as seen before storing its lines, so when storing failed the retry (same event id) was answered 200 duplicate and those lines never reached the books. It now answers 503 INGEST_FAILED and processes the retry; a delivery left unfinished for two minutes is taken over by its retry too. Retry on 503 with the same body; nothing is stored twice.

· added · #

Reverse an invoice or bill payment; pay part of a bill, on a date, from a chosen account

POST /api/v1/invoices/{id}/payments/{paymentId}/void and POST /api/v1/bills/{id}/payments/{paymentId}/void reverse one payment: its journal is reversed (dated today), bank rows posted to it return to review, and the document steps back (invoice to partial/finalized, bill to approved). Optional body {reason}. POST /api/v1/bills/{id}/pay accepts an optional body {amount, payment_date, payment_account_id}; a bill part-paid on the web can now be finished over the API (it used to answer 409). With no body it pays the whole balance today as before; a part payment returns fully_paid false and the outstanding balance, and bill.paid is emitted only when the bill is settled. POST /api/v1/invoices/payments/match-deposit now marks the deposit posted to the payment's journal and refuses money out (422 NOT_A_DEPOSIT), a deposit already posted elsewhere (409 BANK_TRANSACTION_ALREADY_POSTED) and a payment with no ledger entry (409 PAYMENT_NOT_POSTED).

· added · #

Invoices can carry sales tax and credit each line to its own income account

POST /api/v1/invoices (and /api/v1/invoices/create) accept an optional tax_rate (percent, up to 4 decimals, at most 30) and, on each line, an optional income_account_id (one of the business's active income accounts; any other id is 422 VALIDATION_ERROR) and taxable (default true). tax_amount is tax_rate applied to the taxable lines, rounded to the cent, and total is now subtotal + tax_amount. Invoices returned by the API include tax_rate, and their lines income_account_id and taxable. POST /api/v1/invoices/{id}/finalize now credits each line's income account (lines without one, and every invoice made before this, credit the default income account exactly as before) and credits Sales Tax Payable for tax_amount. Requests that send none of the new fields behave as they did.

· changed · #

Invoice emails attach the PDF and state the balance due; contacts carry billing_address

POST /api/v1/invoices/send/{invoiceId} (and /api/v1/invoices/{invoiceId}/send) now attach the invoice PDF to the customer's email, list the line items, and give the balance due (with total and paid once something has been paid). When the server has an https APP_URL the email also links a signed, expiring copy of the PDF. The PDF's seller block carries the business email, phone, address and logo set under Entity Settings, and its Bill To block the contact's billing address. Contacts returned by GET /api/v1/contacts and /api/v1/contacts/{id} include billing_address (string or null, set on the web contact form, read-only over the API). Request and response shapes are otherwise unchanged.

· added · #

Contacts, the review queue, statements and COA imports can be paged; contacts can be searched

GET /api/v1/contacts, /api/v1/review-queue/pending-ai-suggestions, /api/v1/statements and /api/v1/coa-imports accept page and per_page (clamped to 200 a page). When either is sent the response adds data.pagination (page, per_page, total, total_pages, has_more) beside count. Without them nothing changes: contacts and the review queue come back whole, statements and imports as the newest 100. GET /api/v1/contacts also takes q, a case-insensitive match on name, display name or email.

· security · #

Receipt upload/OCR, bank connection sync and Plaid webhook replay check the caller's role

POST /api/v1/receipts/upload, /api/v1/receipts/ocr, /api/v1/receipts/ocr/feedback and /api/v1/bank/connections/{id}/sync now require the owner or bookkeeper role (403 FORBIDDEN otherwise), matching the web screens; an accountant is read-only. POST /api/v1/plaid/webhook/replay/{webhookId} is limited to an owner of the business that owns the stored webhook's Plaid item; anyone else, and an unknown id, gets 404 WEBHOOK_NOT_FOUND, and a failed replay no longer echoes the internal error text. Integrations that call these as an accountant should use an owner or bookkeeper login.

· added · #

Contacts carry is_1099_vendor, tax_id_last4 and tax_address

GET /api/v1/contacts and /api/v1/contacts/{id} (and the contact returned by POST/PUT) now include three fields for 1099-NEC reporting: is_1099_vendor (boolean), tax_id_last4 (the last four digits of the vendor's tax ID, or null) and tax_address (the 1099 mailing address, or null). They are set on the web contact form and are read-only over the API; the full tax ID is stored encrypted and is never returned. Existing fields are unchanged.

· added · #

Redeliver a failed webhook delivery, and rotate an endpoint's signing secret

POST /api/v1/webhooks/endpoints/{id}/deliveries/{deliveryId}/redeliver queues one failed delivery again with fresh attempts (202; 409 DELIVERY_NOT_FAILED or WEBHOOK_ENDPOINT_INACTIVE). POST /api/v1/webhooks/endpoints/{id}/rotate-secret returns a new signing secret once; every delivery from then on, queued retries included, is signed with it. Both need write scope and the owner role. The Developer Portal has the same actions on each endpoint's Deliveries page, and can now edit an endpoint.

· security · #

A connected app sees only its own webhook endpoints, and disconnecting it ends them

Called with an OAuth app token, /api/v1/webhooks/endpoints and everything under it now cover only the endpoints that app created; another endpoint in the business answers 404 WEBHOOK_ENDPOINT_NOT_FOUND. When the customer disconnects the app, the endpoints it created for that business are deactivated and nothing more is delivered to them; on reconnecting, re-activate (PUT is_active=true) or create them again. API keys and sign-in tokens still manage every endpoint of the business.

· fixed · #

Webhook retries now reach the documented schedule; PHP signature snippet corrected

max_retries counts retries after the first attempt, so the default 5 retries after 1, 5, 15, 30 and 60 minutes (it used to stop after 30). Deliveries use the endpoint's timeout_seconds (at most 30). The PHP verification snippets in the docs and the PHP SDK README read $_SERVER['HTTP_X_HASSLEFREBOOKS_SIGNATURE'], which is how PHP exposes the X-HassleFreBooks-Signature header we send; the old two-E key was always empty.

· fixed · #

Browser apps can call /oauth/token and /oauth/revoke; CORS allows and exposes the API's headers

/oauth/token and /oauth/revoke answer CORS preflights from any origin (no credentials), so a single-page public client can exchange its code and refresh. A single-URI app may leave redirect_uri out of the token request when it left it out at /oauth/authorize. API preflights now allow X-Entity-Id, X-API-Key, X-Request-Id and Accept, and responses expose Retry-After, X-RateLimit-*, X-Request-Id, Deprecation, Sunset and Link. Connected apps get 403 NOT_AVAILABLE_TO_APPS on /api/v1/api-keys/* and /api/v1/usage/keys, as already promised for API-key routes.

· fixed · #

GET /api/v1/reports?report_type=profit_loss returns the whole statement with totals

The profit & loss used to page income and expense accounts separately while counting them together, so pages and has_more disagreed with the rows returned. It now behaves like the balance sheet: every row on page 1, pagination reports one page, later pages are empty, and a new totals object carries total_income, total_expense and net_income. GET /api/v1/reports/pl also now lists accounts that have no account code, which it used to leave out of rows while counting them in totals.

· fixed · #

GET /api/v1/reports/sales-by-customer counts posted sales, before tax

total_income now counts only finalized, partial and paid invoices (a sent invoice has not been finalized, so its revenue is not in the books) and excludes sales tax (invoice total minus tax_amount). Figures for periods that held sent or taxed invoices will be lower and will now agree with the profit & loss.

· changed · #

GET /api/v1/transactions/{id} answers for one business, like the list

The detail endpoint now reads from the business the request names (?entity_id or X-Entity-Id) or, when it names none, the caller's active business, exactly as GET /api/v1/transactions, /transactions/{id}/dimension-tags, /invoices/{id} and /bills/{id} already do. It used to return a transaction from any business the caller belongs to. A transaction in another of the caller's businesses is now 404 NOT_FOUND; switch entity (POST /api/v1/entities/switch) or send entity_id to read it. This is a tightening: it closes an inconsistency and no client can have relied on it to see data it was not entitled to.

· fixed · #

GET /api/v1/transactions refuses an unreadable start_date/end_date instead of ignoring it

A start_date or end_date that is not a real Y-m-d calendar date (2026-02-30, 2026-13-01, "last week") now answers 400 INVALID_DATE_FORMAT, and start_date after end_date answers 400 INVALID_DATE_RANGE. Both used to be dropped silently, which returned every transaction. An empty value still means no filter.

· fixed · #

Ledger writes: business-day dates, no posting to deactivated accounts, account type fixed once used

POST /api/v1/journal-entry and /api/v1/expenses judge "in the future" by the business's own calendar (Entity Settings time zone), so an entry dated the business's today is no longer refused for part of the day. The default expense_date, the bill_date/due_date defaults of POST /api/v1/bills and GET /api/v1/bills/create-defaults, and the date a bill payment posts on are the business's today too. POST /api/v1/transactions now refuses a deactivated account with the same 422 INVALID_ACCOUNT (error.details.account_ids lists the ids). PUT /api/v1/coa/{id} answers 409 ACCOUNT_HAS_ACTIVITY when the change would alter the type of an account with posted lines, or move it into or out of the bank/cash subtypes; renames and code changes are unaffected.

· fixed · #

Receipt OCR re-run reads PDFs and text, is owner/bookkeeper only, and respects confirmed values

POST /api/v1/receipts/{id}/extract now reads PDF and plain-text receipts with their own readers instead of sending them as JPEG, and answers 422 UNSUPPORTED_RECEIPT_TYPE for a type it cannot read. It requires the owner or bookkeeper role (403 otherwise) and answers 409 RECEIPT_ALREADY_CONFIRMED for a receipt whose values a person confirmed, unless the body carries "replace": true. POST /api/v1/receipts/{id}/confirm now answers 422 for a date that is not a real calendar date instead of erasing the stored date. An answer the OCR model returns that cannot be read is now reported as an OCR failure (502) rather than saved as an empty successful reading.

· fixed · #

POST /api/v1/auth/mfa/verify reports a lockout and counts each attempt once

A wrong code now uses one unit of the per-user wrong-code budget instead of two, so a person gets the documented 10 tries an hour. Once the budget is spent the endpoint answers 429 MFA_LOCKED with a Retry-After header instead of 401 INVALID_MFA_CODE; show the person the wait rather than asking for another code. A digits-only code is checked as an authenticator or SMS code, anything else as a backup code. Authenticator codes are now single use: a code that was just accepted is refused if sent again (401 INVALID_MFA_CODE).

· added · #

OAuth 2.1 for third-party apps, and the developer console

Register an app at /developers/apps and let any HassleFreeBooks customer connect it to one of their businesses with the authorization-code flow and PKCE (/oauth/authorize, /oauth/token, /oauth/revoke, /oauth/introspect; metadata at /.well-known/oauth-authorization-server). An app token acts for one person in one business: a request naming another entity_id answers 403 ENTITY_NOT_GRANTED, and account-level routes (API keys, entity switching, invitations, push devices, notification settings) answer 403 NOT_AVAILABLE_TO_APPS. API keys keep working exactly as before.

· added · #

Versioning and deprecation policy

Breaking changes now ship alongside the old behaviour, are announced here with a sunset date at least 90 days out (180 for anything touching money or authentication), carry Deprecation, Sunset and Link headers on every affected response, and are emailed to the developers whose apps or keys called the affected endpoints in the last 90 days. Security fixes are the only exception.

· added · #

PHP, JavaScript/TypeScript and Python SDKs, and a Postman collection

Generated from the OpenAPI document and regenerated whenever it changes. Download them from /developer-portal/sdks.

· security · #

Login refresh tokens rotate on every use

POST /api/v1/auth/refresh now returns a new refresh_token each time and the old one stops working. Presenting a rotated token again (outside a short retry window) revokes the whole sign-in with 401 REFRESH_TOKEN_REUSED. Store the refresh_token from every refresh response.

· added · #

Finalize invoices and approve bills over the API; webhook subscriptions

POST /api/v1/invoices/{id}/finalize and POST /api/v1/bills/{id}/approve post the ledger entries that make a document payable. /api/v1/webhooks/endpoints manages webhook subscriptions per business.

· fixed · #

Response contract made consistent

error.details is always an object; every 429 carries Retry-After and X-RateLimit-* headers; an uncaught failure under /api/ is a JSON envelope; boolean flags are JSON booleans, not 0/1; a foreign record answers 404 everywhere. Money fields accept "1,500.00" and are read exactly.