Nordlet

Blog

Pre-Production Test Plan for an Accounting API

A reliability playbook for engineering teams: duplicate writes, idempotency reuse, webhook signatures, out-of-order events, decimal money, scopes, period locks, and recovery.

Nordlet Team · · 15 min read

A 201 Created from an accounting API tells you almost nothing. It confirms the server answered. It does not confirm that exactly one invoice exists, that debits equal credits, that the document number was allocated once, or that a retry after a dropped connection will not book a second copy. Most production accounting duplicates come from that gap: a mutation that committed on the server while the client sat with an unknown outcome and quietly retried with a fresh key.

This plan is for teams evaluating an accounting API before they wire it into real books. It focuses on failure behavior, not happy paths. Each area below states what to test, what to assert, and what counts as a pass. The implementation context here is Nordlet, whose API uses scoped bearer keys, decimal-string amounts, idempotency keys, and signed webhooks. For exact endpoint syntax and response contracts, read the API conventions and Getting started pages rather than trusting assumptions.

What "passing" actually means

A test proves an accounting integration is safe only when it verifies semantics, not status codes. For every scenario, the suite should confirm:

  • Business side effect: exactly one customer, invoice, journal entry, number allocation, or payment transition exists.
  • Accounting integrity: debits equal credits, and monetary precision survives serialization.
  • Replay behavior: a retry returns the original result instead of a new record.
  • Durability: audit records and webhook events exist once the operation commits.
  • No-side-effect failures: rejected requests consume no document numbers and write no partial ledger lines.
  • Recovery: a transient error can be retried without duplication or permanent loss.

Google's API guidance draws the line clearly: only operations whose repeated execution leaves the same final state are safe to retry, and an API should identify which operations are idempotent rather than retrying every failure blindly.

Set up a disposable, observable environment

Use a sandbox company, not production books. A Nordlet sandbox company uses the same modules and API behavior as a real company and is badged as test data, which makes it safe for high-volume duplicate and failure tests. The flag is immutable after creation, so real books cannot be reclassified as test data later. Note that sandbox usage is metered and billed exactly like usage in a real company, so size your volume tests against the pricing you are on.

Before writing any test, prepare:

  • A dedicated sandbox company with isSandbox: true.
  • A test API key scoped only to the modules under test.
  • A webhook endpoint or local tunnel that preserves the raw request body.
  • Seeded customers, suppliers, and a known chart of accounts.
  • At least two accounting periods: one open, one locked.
  • A test clock or date fixture if the implementation supports one.
  • A local store recording every request attempt, idempotency key, response hash, webhook delivery, and reconciliation result.

Do not run duplicate tests against shared staging data. A stray customer or invoice from an earlier run makes later assertions ambiguous.

Your fixture for every mutation should capture the method, endpoint, full body, idempotency key, key scope, a client correlation ID, start and finish timestamps, the response status, headers, body, request ID, and whether the connection dropped before the response arrived. Use fixed decimal strings such as "75.0000" for money. Nordlet represents monetary values as decimal strings with up to four decimal places, and its example invoice uses that form.

The other half of setup is failure injection. Your harness should be able to delay a response, drop a TCP connection after the server received the request, return synthetic 429/500/502/503/504 responses, return a non-2xx from the webhook receiver, deliver the same webhook twice, reorder events, corrupt one byte of a webhook body, change an idempotency-key payload while keeping the key, run two identical requests concurrently, and lock a period between setup and posting. These scenarios are worth more than a hundred happy-path examples.

Lock the contract before writing retry logic

Generate or fetch the OpenAPI schema and compare it against your client. Nordlet publishes its OpenAPI spec in the API reference and generates its typed SDKs from that same spec, so schema-driven tests beat hand-written guesses.

Verify required fields and types, decimal-string fields, date and timestamp formats, allowed actions and scopes, the error envelope shape, webhook event schemas, which mutations accept an idempotency key, and which response fields identify the created object.

Pass criteria. The client rejects invalid payloads locally where the schema allows. The server's responses match the documented schema. Invalid decimals, bad dates, and missing required fields produce structured validation errors. No retry is attempted for a deterministic validation failure.

Failure mode to catch. A client parses "121.0000" into a binary float and serializes a slightly different amount later. Whole numbers pass; VAT lines and fractional quantities fail. Test money as strings end to end.

Authorization and tenant isolation

Use separate keys for read and write. Then test the full grid: missing key, invalid key, valid key with insufficient module scope, valid key with the right scope, a key belonging to another company, and an attempt to read an object from a different company.

Assertions:

  • Missing or invalid credentials return 401.
  • A valid key without the required permission returns 403.
  • No accounting side effect is created for either failure.
  • A successful request cannot read or mutate another company's records.
  • Security failures are never retried automatically.

Pass criterion. You can prove both authorization correctness and tenant isolation with post-test reads, not just status codes.

Prove one clean transaction

Run a small, deterministic flow: create or find a test customer, create an invoice with decimal-string amounts, issue it, read the invoice and its accounting effect, read the audit record, and receive the webhook. Nordlet's Getting Started example shows invoice issuance allocating a gapless number, checking that the period is open, and posting a balanced journal entry in the same transaction.

Assert that the invoice reaches the expected state, the document number is allocated exactly once, debits equal credits, VAT and line totals match the expected half-up calculation, the audit record names the actor and change, the webhook is eventually delivered, and the API response and a follow-up read agree on the invoice ID and state.

One distinction worth holding onto: a webhook arriving is not proof that the mutation committed. The API response plus a read-back are your primary transaction assertions. The webhook proves downstream notification, and it is tested separately.

Idempotency: same key, same body

Generate one key per logical business operation, not one key per network attempt. Send the mutation with key K and body B, save the created ID, then send the exact same request again.

Assert that the second response represents the same operation, the object ID is identical, and there is one business object, one posting, one document number, one financial effect. The replayed response should carry the documented replay header — x-idempotent-replay: true in Nordlet's case — and the audit trail should show no second mutation.

Two storage rules change what your retry loop should expect, so test them directly: a stored 4xx is replayed like any other response, while a 5xx is not stored at all, so a retry after a server error re-executes the operation rather than replaying. Keys also expire, in Nordlet after 24 hours, which bounds how long a replay is available.

Nordlet's documentation promises that adding an Idempotency-Key header prevents a retried request from creating a duplicate, and its API conventions define the exact replay header and byte-for-byte response requirement. The principle matches Stripe's published model: a key identifies the logical operation, repeats return the stored result, and a key reused with different parameters is rejected rather than silently changed.

Pass criterion. Exactly one financial state transition exists no matter how many times the client sends the same request inside the replay window.

Idempotency: same key, changed body

Send the original request with key K and body B1, then a second request with key K and body B2 where at least one amount, customer, date, or line differs.

Expected result: the request is rejected with the documented key-reuse error — Nordlet returns 422 idempotency_key_reuse — the original record is untouched, no second record or number or audit mutation appears, and the client does not "fix" it by generating a new key. Silently accepting B2 would make retry keys unsafe.

Concurrent duplicate requests

Fire two identical requests with the same key at the same instant. Depending on the documented behavior, one executes while the other receives an in-progress conflict — Nordlet returns 409 idempotency_in_progress — or both eventually observe the same result.

Assert that both calls cannot create independent financial effects, at most one document number is allocated, a temporary conflict is retried with the same key rather than a new one, the final resource is complete and readable, and the idempotency record does not stay permanently stuck.

Failure mode. A retry library that mints a fresh UUID per attempt turns one logical operation into several independent writes and defeats idempotency entirely.

The uncertain outcome after a dropped connection

This is the single most important production simulation. Send a mutation with key K, let the server commit it, drop the connection before the response arrives, record the outcome as unknown, then retry with the same key K and reconcile by reading the resource and audit data.

Assert that the retry returns the original or documented replay response, exactly one financial side effect exists, the client marks the operation complete only after a successful replay or reconciliation read, and no new key is generated. A timeout does not mean "nothing happened." Stripe describes idempotency precisely as protection against connection errors where the server may have processed a mutation before the client could receive the response.

A conservative retry policy

Retry decisions depend on both the error and whether the operation is idempotent, not on the HTTP method alone.

Usually retryable, same key retained: connection reset or timeout after an idempotent mutation, 408, 429 after honoring retry-after, and 502/503/504. A documented in-progress idempotency response can be retried after a short delay.

Not retryable without reconciliation: 400/422 validation errors, 401/403 auth failures, 404 for a missing resource, business 409 conflicts unrelated to an in-flight key, a changed payload on an existing key, and a period-lock rejection.

Assert bounded exponential backoff with jitter, explicit maximum attempts and elapsed time, retry-after honored for rate limits, the same key kept across retries of one operation, a new key for each new operation, and logs that separate the original attempt from retries.

Test rate limiting on its own. Cross the documented threshold — Nordlet allows 300 requests per minute per API key by default, and some unauthenticated endpoints carry their own lower per-address limits — then confirm the server returns the rate-limit response, the client parses retry-after as a delay rather than an error, it pauses instead of hammering, and a throttled mutation retry reuses the original key. The x-ratelimit-* headers report the remaining budget, which makes a good pre-assertion. Keep rate-limit tests away from financial assertions unless your fixture can tell a throttled attempt from a committed one.

Webhook signature verification

Build a receiver that preserves the raw bytes. For each event: capture the raw body, read the x-nordlet-signature header, compute the expected HMAC-SHA256 with the subscription secret, compare using a constant-time function, parse JSON only after verification, record the delivery, and return a fast 2xx after durable acceptance. Nordlet signs an HMAC over the raw body, sends it as sha256=<hex>, and retries deliveries with exponential backoff. Stripe's webhook guidance reinforces verifying the unmodified raw body with a constant-time comparison.

Run negative tests: correct body and secret, correct body with wrong secret, a one-byte body change, re-serialized JSON with altered whitespace, a missing header, a malformed signature, a replayed old delivery if the scheme includes a timestamp, and a valid signature delivered to the wrong subscription.

Pass criteria. Only the correct raw body and secret are accepted. Invalid signatures get a non-2xx. Invalid payloads never enter the accounting queue. Secrets stay out of ordinary logs. The handler acknowledges only after the event is durably recorded.

Duplicate and out-of-order webhooks

Delivery reliability is a separate problem from API idempotency, and the receiver must be idempotent too. Deliver the same event twice, deliver a sale_invoice.paid event before a sale_invoice.issued event, return a 500 after persisting but before responding, and replay an event the consumer already processed.

Assert that duplicate deliveries do not duplicate payments, status changes, or journal actions, that the deduplication key uses the provider's delivery or event ID when one exists (or a documented composite key, never an arbitrary timestamp), that out-of-order events never overwrite newer state with older, that the consumer can fetch current state from the API when order is insufficient, and that failed consumer attempts route through a queue or dead-letter path. Stripe warns explicitly that delivery order is not guaranteed and recommends storing processed event IDs.

Keep the endpoint thin: validate shape, verify signature, persist or enqueue, acknowledge, return 2xx. Reconciliation and notifications belong in async processing. Test it by making the downstream work sleep past the delivery timeout; a correct receiver still acknowledges after durable queueing.

Open and locked accounting periods

Nordlet enforces period locks at posting time across manual journals, document flows, and imports, so test the control in more than one place. Create equivalent documents dated in an open period, in a locked period, at the boundary just before the lock, on the first day of the next open period, and with a valid document date but invalid posting date if the API separates the two.

For the locked-period case, assert that the request is rejected with the documented period-lock error — a 409 conflict in Nordlet — and that no journal lines are written, no document number is consumed, no document transitions to posted, and no success webhook fires. The open-period version should succeed with the same financial values.

Race test. Start a posting against an open period, lock the period mid-request, and confirm the commit cannot bypass the lock. The critical pass condition: a committed posting never lands in a period that was locked before the transaction committed.

Auditability and reconciliation

For every successful mutation, confirm the business object exists, the accounting entry exists with debits equal to credits, the audit record exists, the webhook event exists or is retrievable, and your operation record points to the provider object ID with retries referencing the same logical operation.

For every rejected mutation, confirm no partial document exists, no number was consumed unless the contract allows reservation, no ledger entry exists, no success webhook exists, and the error carries a request ID for diagnosis. Nordlet's conventions describe mutation auditability and balanced postings as platform guarantees, with balance enforced by a deferred database trigger at commit time rather than by application code alone. Turn those guarantees into post-test reconciliation queries instead of trusting the HTTP response.

Core test matrix

Area Test action Expected assertion Pass criterion
Authentication No key, invalid key, insufficient scope Structured 401/403; no side effect All rejected attempts create no accounting record
Schema and money Invalid decimal, date, required field Field-level validation error No retry or partial write occurs
Normal posting Create and issue an invoice in an open period One document, one balanced journal, one audit record Read-back state and ledger agree
Same-key replay Repeat identical mutation, same key and body Original result replayed; no duplicate Object ID, number, journal effect, audit stay singular
Changed body, same key Reuse key with a different amount Documented key-reuse rejection Original record unchanged; nothing new written
Dropped connection Commit, drop, retry with same key Replayed result Exactly one financial side effect
Locked period Post a document dated in a locked period Period-lock rejection No journal, no number, no success webhook
Webhook signature Wrong secret, altered body Non-2xx from receiver Only correct raw body and secret accepted

What I would do first

If time is short, run the tests in the order that catches the most expensive bugs. Start with the dropped-connection retry, because that single scenario produces most real duplicates. Then the same-key and changed-body idempotency pair. Then locked periods, since a posting in a closed month is an audit problem that surfaces late. Signature verification and out-of-order webhooks come next, and the full schema and authorization grid can run continuously in CI once the client stabilizes.

Everything else in this plan supports those five. Get them green against a sandbox company, with reconciliation reads confirming the side effects, and you will know the integration is safe before it touches a single real ledger. You can create a sandbox company and a scoped test key in a few minutes from Get started, and check which modules you need against the features list.

FAQ

Is a 2xx response enough to confirm an accounting write succeeded?

No. A status code confirms the server answered, not that exactly one document, one number allocation, and one balanced journal entry exist. Every mutation test should follow the response with a read-back of the object, its accounting effect, and its audit record. The dangerous case is the opposite direction: an operation that committed while the client never received the response at all.

Should I generate a new idempotency key when a request times out?

No, and this is the mistake that causes most production duplicates. The key identifies the logical business operation, not the network attempt. Retry the same key, then reconcile by reading the resource. A retry library that mints a fresh UUID per attempt turns one intended invoice into several independent writes.

What happens if I reuse an idempotency key with a different payload?

A correct API rejects it rather than silently applying the new body. Nordlet returns 422 idempotency_key_reuse and leaves the original record untouched. Your client should treat this as a programming error to fix, not as a condition to work around by generating a new key.

Are webhooks a substitute for reading back the created object?

No. Webhook delivery proves downstream notification, not that the transaction committed the way you expect, and delivery order is not guaranteed. Verify the signature over the raw body, deduplicate on the delivery or event ID, and fetch current state from the API whenever event order alone is not enough to decide what changed.

How do I test posting into a locked accounting period?

Create equivalent documents in an open period and in a locked one, and assert that the locked case is rejected with no journal lines, no consumed document number, no state transition, and no success webhook. Then run the race version: start a posting against an open period and lock the period mid-request. A committed posting must never land in a period that was locked before the transaction committed.

Can I run high-volume duplicate tests in a sandbox company?

Yes. A sandbox company behaves like a real one, is badged as test data, and deletes immediately instead of after the retention window, so it is the right place for destructive and duplicate testing. Sandbox usage is still metered and billed like real usage, so plan the volume rather than looping indefinitely.