Version 4.2.0
Release Date: Security EnforcementsAdds security enforcements, server-side money rounding, durable request replay, opaque pagination, unchanged-update detection, batch ingestion, and content-hash synchronization. Payload field set is unchanged. Enforcements can reject unclean requests; check payloads and clean them if failures happen.
API 4.2.0, documented 2026-09-14. Confirm availability in your target environment. The content-hash v2 creditor repair requires an operator-run backfill and refreshed sync-state caches; omitted update creditors remain preserved. Local hashing failures should send normal updates. Earlier entries describe historical behavior.
Choose a topic above. Only selected section opens.
Updated: the API rounds money to cents
Amounts with more than two decimal places are accepted. For both POST /ingest and POST /ingest/batch, the API rounds each monetary field to the nearest cent, with halfway cases rounded away from zero, before storing the event, calculating derived amounts, comparing content hashes, or writing domain rows.
Payload compatibility: No new fields or payload structure are required. Existing valid cent-valued amounts keep their value. Clients no longer need to pre-round amounts to avoid a precision error. This replaces the earlier rejection rule and is not a breaking payload change.
- Send finite JSON numbers, not numeric strings. Scientific notation is accepted. Rounding uses decimal arithmetic on the shortest decimal representation of the parsed JSON number, not binary multiplication or Math.round. Precision already lost while constructing or parsing a number cannot be recovered.
- Rounded values and calculated amounts must fit NUMERIC(15,2): -9999999999999.99 to 9999999999999.99. Overflow still returns HTTP 400; extra decimal places alone do not.
- Existing sign restrictions apply to the submitted value: a negative amount is still invalid for a nonnegative field, even if it would round to zero. Negative examples below only apply to fields that allow negative values. Other numeric business bounds are checked after rounding.
- Ratios such as insurer_coverage, counts, and values nested inside info are not rounded as money. Omitted and nullable fields retain their existing behavior. Zero is normalized without a negative sign.
| Submitted number | Normalized amount |
|---|---|
10.254 | 10.25 |
10.255 | 10.26 |
-10.255 | -10.26 |
1.005 | 1.01 |
-0.004 | 0.00 |
1.005e1 | 10.05 |
Rounded fields: disbursement_amount, gross_value, upfront_fee, total_expected_margin, approved_amount, operation_revenue, operation_cost, asset_value, depreciation_amount, payment_total, payment_principal, payment_fee.
Derived amounts use rounded components
Components are rounded separately before calculation. With gross_value omitted, disbursement_amount: 10.005 plus upfront_fee: 10.005 produces gross_value: 20.02, not 20.01.
Same rounded value means the same content hash
Otherwise identical updates containing 10.251, 10.254, or 10.25 normalize to the same money value and content hash. With 10.25 already stored, they return 200 with action=unchanged. Hash version remains 2; this rounding update alone requires no hash-version change or backfill.
Same rounded value does not mean the same retry
Request idempotency still compares the original submitted content, not its rounded equivalent. Reusing one key with 10.251 and then 10.254 returns 409 IDEMPOTENCY_KEY_REUSED even though both round to 10.25. Preserve the original amount and key for an exact retry.
Security enforcements: can trigger failures
This is not a breaking change. These security enforcements can reject requests. Payload fields stay the same. If a request fails, clean the payload and retry.
-
Retries no longer return a generic duplicate response
- Before
- Repeating an Idempotency-Key returned HTTP 200 with status=duplicate, even if the original request had a different result.
- Now
- An identical stored request replays its original HTTP status and response body. A successful create replays as 201, an unchanged update as 200, and a stored terminal processing error replays that error. JSON object-property order does not matter.
- If it fails
- Remove checks that require status=duplicate or expect every retry to return 200. Interpret the returned status/body normally. After a timeout or lost response, retry the same request with the same key; do not generate a new key just because the response was lost.
-
An idempotency key cannot be reused for different content
- Before
- Reusing an existing key could return the generic duplicate response without reporting that the category, step, or payload had changed.
- Now
- Changed request content under a stored key returns HTTP 409 with error=IDEMPOTENCY_KEY_REUSED. Keys are tenant-scoped, case-sensitive, nonblank, and limited to 255 UTF-8 bytes. Stored keys have no cache-based expiry and remain reserved while their event records exist.
- If it fails
- Use one key per logical event and preserve it for retries. Use a new key for a changed request, including a correction to a terminally recorded failure. Never assume that waiting for a cache timeout makes a used key reusable. Validation failures before persistence and unexpected rolled-back failures are not stored terminal replays.
-
Raw IDs and timestamps are no longer valid pagination cursors
- Before
- ID-list examples used the last business ID, such as client-002, as the next cursor. Row pagination could use a raw sort value.
- Now
- Read endpoints require the opaque token returned in pagination.next_cursor. It carries the sort value and any primary-key tiebreakers. A raw ID, timestamp, or malformed token returns HTTP 400 Invalid cursor.
- If it fails
- Discard stored legacy cursors and restart from the first page. URL-encode and reuse next_cursor without constructing or decoding it, keeping the same table, filters, and sort order. Stop when has_more is false. Row lists exclude records whose selected sort value is null.
-
Unknown payload fields are rejected instead of ignored
- Before
- Extra envelope or category/step fields could pass validation. A permissive local validator was not evidence that the API supported those fields.
- Now
- Unknown envelope and data fields return HTTP 400. Single ingestion accepts category and data at the envelope level; allowed data fields depend on category and data.step. Sending a server-generated content_hash is also rejected.
- If it fails
- Send only declared fields for the selected category and step. Strip database/audit fields from outgoing records and use declared info metadata where supported. Keep explicitly supported legacy fields such as payment/operation entity_type and entity_id separate from genuinely unknown fields.
-
An unchanged update returns 200 and produces no domain changes
- Before
- Accepted updates returned HTTP 201 and could rewrite the row even when its business content had not changed.
- Now
- The API merges a partial update with the current row and compares its content hash. An equal hash returns HTTP 200 with status=unchanged and action=unchanged. It does not change domain timestamps or generate child/history effects; an ingest event and response are still recorded.
- If it fails
- Treat 200 unchanged as success, not as an error or a reason to retry with a new key. Use action to distinguish created, updated, unchanged, and deleted. Do not rely on unchanged updates to refresh domain timestamps or trigger history rows. Creates and changed updates remain 201; deletes also return 201.
-
Purchase payments must reference a linked purchase client
- Before
- A purchase payment needed client_id, but membership in the parent purchase allocation was not enforced.
- Now
- The payment client must be linked to the purchase through purchase_clients. An unlinked client returns HTTP 400 with a purchase payment client mismatch error. Loan payments continue to resolve the client from the parent loan.
- If it fails
- Create the client and establish its purchase_clients link before submitting the payment. An existing client record alone is not enough. Correct a rejected payment using a new idempotency key if the original failure was stored.
-
Disbursements with active dependents cannot be deleted
- Before
- Deleting a disbursement did not enforce the current active-dependency guard.
- Now
- A delete returns HTTP 409 when active dependent records exist, including payments, operations, and applicable history or legacy dependencies. The API does not cascade the delete through those records.
- If it fails
- Check and resolve dependencies using supported workflows before retrying. Do not blindly delete financial history to bypass the guard; contact support for dependencies you cannot resolve. Treat this conflict differently from IDEMPOTENCY_KEY_REUSED.
-
Request limits and tenant readiness can reject writes before processing
- Before
- Clients could rely on less restrictive request handling or on runtime schema setup. List limit values were less strictly bounded.
- Now
- Single and batch ingestion bodies are capped at 1 MiB; oversized bodies return 413. Table row/ID lists accept integer limits from 1 to 1000 (default 50); invalid limits return 400. Ingestion and sync-state return 503 SCHEMA_NOT_READY if tenant setup is incomplete.
- If it fails
- Enforce UTF-8 request size limits before sending. Split bulk work into bounded requests, not one oversized event, and validate list limits locally. Pause ingestion and contact support for SCHEMA_NOT_READY; repeated retries do not perform tenant setup.
Payload check
Required check: confirm no bad payload is formed. Enforcements can trigger failures. If a request fails, clean the payload and retry.
- Verify availability in your target environment and confirm tenant readiness. Check payloads locally so bad requests fail before production traffic.
- Update response handling first: accept normal 201 successes, 200 unchanged, exact stored replays, and per-item batch statuses.
- Persist retry keys per logical event; separate same-request retries from changed or corrected requests.
- Strip unknown fields and send finite numeric amounts; the API rounds money for you. If a request fails, clean the payload and retry. Use a new key only when the original failure was stored. Keep sign/range validation and key/body/page-size limits, but do not reject amounts solely for having extra decimals.
- Discard legacy cursors and restart pagination using only returned next_cursor values.
- Check purchase-client links and disbursement dependencies before writes or deletes.
- If computing content hashes locally, match the parsed-number rounding semantics and individually rounded derived amounts. This update keeps hash version 2; no backfill is needed for rounding alone. Otherwise retain ID-based reconciliation.
- Test halfway rounding, negative/sign-restricted values, derived sums, rounded-equivalent updates, and raw-amount key conflicts, alongside timeout/retry, overflow/unknown-field errors, paginated reads, and mixed-result batches before rollout.
