sandbox.bankconnector.com API Reference

Receiving results: webhooks & polling

Audience: 🔌 Integration Client

Delivery is asynchronous, so you learn what happened to a payment (and about incoming statements) through webhooks or polling. Most integrations use both: webhooks for latency, polling as a safety net.


Part A: Webhooks

BankConnector POSTs a signed JSON event to your HTTPS endpoint whenever something happens. This part covers the receiver side: envelope, verification, retries, the event catalog. To create and manage endpoints, see Webhooks.

Register an endpoint with POST /api/webhooks; the 201 returns the signing secret whsec_… once. Fields, transports, filtering and secret rotation are in Webhooks.

The event envelope

{
  "version": "1.0",
  "id": "<eventId>",
  "type": "payment.sent",
  "createdAt": "2026-07-02T10: 15: 30.000Z",
  "message": "Payment sent to bank.",
  "data": { "documentId": "...", "journalNo": "...", "bankKey": "...", "...": "..." },
  "companyId": "<companyId>"
}

companyId is the company the event belongs to, and it is the id to key on.

The id is stable across every retry and redelivery of the same event, and it equals the X-BankConnector-Delivery header. Use it as your deduplication key: at-least-once delivery means you can receive the same event more than once.

Verifying the signature

Always verify. Each delivery carries:

X-BankConnector-Signature: t=<ISO timestamp>,v1=<hex HMAC>
X-BankConnector-Event:     payment.sent
X-BankConnector-Delivery:  <eventId>        # dedup key
X-Delivery-Sequence:       <n>              # gap detection

The signature is HMAC-SHA256(secret, "<timestamp>.<rawBody>"), hex-encoded. Verify over the raw request body before parsing it, using a constant-time compare:

import crypto from "node:crypto";

function verify(secret: string, header: string, rawBody: string, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = parts["t"];
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");

  // header may carry multiple v1= values during secret rotation, accept any match
  const provided = header
    .split(",")
    .filter((s) => s.startsWith("v1="))
    .map((s) => s.slice(3));
  const ok = provided.some(
    (sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
  if (!ok) return false;

  // Optional replay window (recommended to enable on your side). Two-sided, like
  // the server: reject both stale AND future-dated timestamps.
  const age = Math.abs(Date.now() - Date.parse(t));
  if (age > toleranceSec * 1000) return false;

  return true;
}
  • Replay-window checking is off by default and accepts any timestamp. Enforce the 300 s window yourself (as above). Retries are re-signed with a fresh timestamp, so a modest window is safe.
  • During secret rotation the signature header carries multiple v1= values (old and new). Accept a match against any of them. Rotate via POST /api/webhooks/<id>/rotate-secret { "overlapHours": 24 }.

Delivery guarantees & retries

  • At-least-once. Success is any HTTP 2xx from your endpoint. Deduplicate on the event id.
  • On non-2xx or timeout, BankConnector retries: 31 attempts, exponential backoff 30 s → 1 m → 2 m → 4 m → 8 m → 16 m → 32 m → 1 h between them, then a flat 1 h wait for each of the 23 attempts after that, about 1443.5 minutes (roughly 24 hours) end to end (±20% jitter). Each attempt has a 10 s timeout.
  • Acknowledge in well under 10 s and do heavy work after you've returned 2xx, keyed on the event id.
  • After the 31st attempt the delivery settles failed and is not retried again automatically. Recover it with POST /api/webhooks/<id>/redeliver-failed?since=<iso> (replays every failed delivery since since) or POST /api/webhooks/<id>/deliveries/<deliveryId>/redeliver for a single one; see the Webhooks guide. After an outage longer than about 24 hours, redeliver once the endpoint recovers.

The failure mode to monitor

After 5 consecutive failures an endpoint is auto-disabled and stops receiving events until you call POST /api/webhooks/<id>/re-enable. To catch this:

  • Watch GET /api/webhooks/<id>/deliveries for failures.
  • Track the X-Delivery-Sequence header: a gap means you missed events.
  • Back everything with a periodic poll (Part B) so a disabled webhook can't lose you data.

Event catalog

A "*" subscription (or one that omits eventTypes) receives every event below, and only these. An event added to this table later reaches it too, so ignore a type you do not recognise.

EventFires when
payment.convertedpayment validated & converted
payment.queuedqueued for delivery
payment.senthanded to the bank
payment.delivery-faileda delivery attempt failed (retryable? in data)
payment.delivery-abandoneddelivery gave up after retries, and nothing was in flight when it did. data.documentStatus carries the payment's status; see the re-issue note below
payment.stuck-sendingdelivery was interrupted while the file was in flight; we do not know whether the bank received it. Do not re-issue; see the re-issue note below
payment.validatedthe bank accepted the payment (pain.002 ACCP/ACTC), not yet settled or settled only in part, when the bank reports part of the file at a time (batch by batch, or PART with no rejected line). Per-payment detail in data.transactions[]; see the note below
payment.pendingthe bank's report says the payment is still pending (waiting for funds or a second signature; reasonCodes in data). Informational: the payment's own status is unchanged
payment.executedthe bank settled the payment (pain.002 ACSC/ACCC, or ACCP in a German instant status report: statusReport: "instant" in data), for every line of the file, across every status report the bank sent on it. Per-payment detail in data.transactions[]; see the note below
payment.rejectedthe bank rejected the payment (reasonCodes in data). Per-payment reasons in data.transactions[]; see the note below
payment.partially-acceptedsome lines accepted, some rejected (reasonCodes in data). Which lines, and why, are in data.transactions[]; see the note below
payment.cancelledthe bank cancelled the payment
approval.neededa payment is waiting for approval
approval.completed / approval.givenfully approved
approval.rejectedan approver rejected it. The payment's status becomes rejected-by-approver (final, never sent); no payment.* event fires for a veto, so this is the one to watch. To pay it anyway, submit a new payment
file.receivedan inbound file arrived
payee-verification.senta verification-only file (Verification of Payee, POST /journal/payee-verifications) reached the bank; nothing is paid. messageId is in data, and the results arrive later as a file.received whose document is the payee-verification report
statement.importeda statement was parsed (includes up to the first 100 transactions; see the note below)
balance.updatedan account balance changed. On a production server, a statement from a bank's test environment changes no balance and sends none
account.statement-gapa sequence gap detected in statement continuity (missing page between two received statements). camt.053 / MT940 only; see the note below
account.statement-integrity-gapa statement's own arithmetic doesn't balance (opening ± booked entries ≠ closing), split from account.statement-gap so the two can be alerted on separately
account.statement-missingan expected statement had not arrived by 10:00 in the bank's country: each bank day's if the bank sends empty ones, else one for a day money moved on the account (expectedBecause says which); once per gap
statement.truncatedan inbound camt file was cut short: entries after the cut, or a whole account's statement, are missing
connection.poll-failinga connection's polling is failing
connection.cert-expiringa bank cert nears expiry. Two sources: our daily scan at 30/14/7 days, and, on Danske, the bank's own status check reporting a certificate as expiring soon, which has no threshold and can arrive at any time
grant.expiringa connected application's authorisation nears its absolute expiry (90/30/7/1 days). Re-authorise the app before then, because on expiry its requests stop authenticating and its payments are refused
webhook.testyou called the test endpoint

statement.imported embeds at most the first 100 transactions. transactionCount is always the full count, and when the statement has more than 100 the payload also carries `transactionsTruncated:

true` so a truncated delivery says so itself. A

statement with 100 or fewer transactions carries no flag and the whole array.

Every free-text field below (counterpartyName, remittance, endToEndId, entryInfo, creditorReference, bankRef) is capped at 2,000 characters, so a full 100-transaction delivery stays under roughly 1.2 MB.

To get every transaction, fetch the document: GET /api/journal/documents/{documentId} (the documentId is in the payload). The stored document is never truncated; only the webhook body is.

Each embedded transaction (data.transactions[]) carries:

  • amount, currency, direction, status, bookingDate: always present. status is one of booked / pending / information / future / unknown, never omitted, unknown included (a real parse outcome, not a placeholder).
  • counterpartyIban, counterpartyAccount: always present, and masked: XX…nnnn (first two characters, last four, nothing between), or the empty string "" when there is nothing to mask (no IBAN / no non-IBAN account on the entry). A masked field's key is never omitted, only shortened to "". counterpartyAccount is the counterparty's non-IBAN account number (BBAN, proprietary), masked exactly like counterpartyIban, never the full number. counterpartyAccount added 2026-09-25. There is no clearing/sort code field yet.
  • valueDate: ISO date. Genuinely absent (the key is not sent) when the bank states none.
  • counterpartyName, remittance, endToEndId: free text, passed through exactly as the bank sent it, the same text the stored document and the CSV export already carry. This endpoint is yours; nothing here is masked. Genuinely absent when the source carries none.
  • entryInfo: the bank's own free-text narrative for the entry (Ntry/AddtlNtryInf). For some banks (Barclays Bacs credits among them) this is the only description an entry carries. Passed through exactly as sent, like remittance. Genuinely absent when the source carries none. Added 2026-09-25.
  • creditorReference: the structured creditor reference (KID / OCR / SCOR / a national scheme like the Belgian OGM), the field Belgian, Nordic and Finnish bookkeeping software matches an incoming payment on first. Passed through exactly as sent, like remittance. Genuinely absent when the source carries none. Added 2026-09-25.
  • bankRef: the bank's own reference for the entry. Passed through exactly as sent, like remittance. Genuinely absent when the source carries none. Added 2026-09-25.
  • txCodeDomain, txCodeFamily, txCodeSubFamily: the ISO bank transaction code (BkTxCd/Domn/Fmly/SubFmlyCd), e.g. domain PMNT, family RCDT. Genuinely absent when the bank states none. Added 2026-09-25.
  • txCodeProprietary, txCodeProprietaryIssuer: the bank's own proprietary transaction code (BkTxCd/Prtry/Cd) and the body that issued it (BkTxCd/Prtry/Issr, e.g. SWIFT, Danske's DBA), for a scheme-specific posting the ISO domain/family/sub-family alone does not identify (a Danish BS/LS lump-sum credit among them). Genuinely absent when the bank states none. Added 2026-09-29.

Two different "nothing to report" shapes: amount/currency/direction/status/ bookingDate/counterpartyIban/counterpartyAccount are always present; the last two read as the masked empty string "", never an absent key, when there is nothing to mask. Every other field above is genuinely optional: its key is not sent at all when the source statement carries none. To detect "no non-IBAN account on this entry" or "no bank transaction code", check truthiness (if (tx.counterpartyAccount)), not key presence. 'counterpartyAccount' in data or !== undefined is always true and tells you nothing.

Some banks' status reports never say a payment was paid. ABN AMRO NL's pain.002 reports acceptance only (its guide reads ACSC as "Accepted"), so an ABN AMRO NL payment stays at payment.validated until a camt.053 statement books its debit, and that statement sends payment.executed with settledBy: "statement" in data.

payment.validated / payment.executed / payment.rejected / payment.partially-accepted embed at most the first 200 transactions from the bank's status report, in data.transactions[]. The same self-describing cap as statement.imported above: transactionCount is always the full count, and the payload carries transactionsTruncated: true only when the report has more than 200. data.documentId is the pain.002 document that carried the report, GET /api/journal/documents/{documentId} returns the rest. data.transactions is absent entirely for a report with no per-transaction detail at all (a bank that answers group-only, e.g. HSBC FR), and the batch-level status/reasonCodes fields are all there is.

Each entry carries:

  • status: always present, one of accepted / accepted-with-change / pending / rejected / received / partially-accepted / cancelled.
  • endToEndId: the transaction's own end-to-end id. For a bank whose report carries none at all (OP's content report), the enclosing batch's payment-info id instead (never a fabricated value), so a per-payment outcome still resolves when the bank identifies the batch by payment-info id only. Absent only when the bank sent neither.
  • reasonCode, bankText: the bank's own reason code(s) and free text for this transaction, when it carries any (several are comma-/semicolon-joined the same way the batch-level reasonCodes field is). bankText is the bank's own words, passed through as sent, except a run inside it that is shaped like a credential, an IBAN or a checksum-valid national identity number, which is masked, and text over 2,000 characters, which is cut with …. Banks word the same code differently, and Danish banks mix Danish and English, so read it as-is.

On an EBICS connection, payment.rejected can also come from the bank's customer protocol (HAC): the bank's technical log of the EBICS orders it received, fetched on every poll. When it reports the upload of a payment as finally refused (a signature that did not verify, a distributed signature that expired), data.statusReport is "ebics-customer-protocol", data.orderId is the bank's EBICS order id, data.paymentDocumentId is the payment and data.documentId is the protocol. There is no data.transactions: the protocol refuses the whole upload, not single lines.

🔴 Before you re-issue a payment automatically

Re-issuing under a fresh MsgId is right when a payment did not go through and wrong when we cannot prove that. Which event you got decides:

  • payment.delivery-abandoned: we gave up before or between attempts. data.documentStatus tells you which: queued-for-delivery, delivery-failed, pending-approval or converted mean no file reached the bank and no payment was made, so re-issuing is safe. Any other value means the bank already has the payment; that payment's own status event (payment.sent / payment.validated / payment.executed / payment.rejected / …) is authoritative and this event does not change it, so do not re-issue on the strength of this one. documentStatus is null only when we could not read the status at all; check the payment before re-issuing.
  • payment.stuck-sending: delivery was interrupted while the file was in flight. The bank may have received and executed it. The payment is held at sending, re-issuing it can pay twice, and clearing it needs a person: confirm with the bank, then resend from BankConnector if it was not paid. This event also fires from the daily SLA sweep for any payment left at sending, and means the same thing both times.

The safe rule for an unattended integrator: automate on payment.delivery-abandoned only when documentStatus is one of the four pre-send values, and never on payment.stuck-sending.

Less common events, worth subscribing to if they matter to your flow:

EventFires when
payment.duplicate-contenta resubmit matched an existing payment's content fingerprint (not the idempotency key path)
payment.duplicate-message-ida payment reused a MsgId already used on a prior submission (same bank+environment); the bank will most likely reject it as "not unique"
pain002.unmatched-paymenta bank status report arrived that matched no outbound payment (originalMessageId in data)
pain002.unmatched-orig-msg-ida bank status report arrived with no / unusable original-message id, so it cannot be matched
statement.unmatched-our-referencea statement debit carries one of your payment references but matches no payment on that connection (possible duplication/data loss; ordinary fees/salary/third-party debits never fire this)
payment.double-debita second statement debit settled an already-settled payment (possible double execution; the payment's status is not changed). camt.053 / MT940 only; see the note below
payment.strandeda queued payment couldn't be picked up for delivery
payment.delivery-blockeda fully approved payment is not sent (reason in data). For the Norwegian AML approver-code reasons it is HELD: the file the bank must receive cannot be rebuilt with its approvers' codes, and once the reason is fixed it goes. For address-deadline-error it is ENDED at delivery-failed: a party's address is address lines with no town (or, from a Danish, Greenlandic or Faroese bank, lacks its town or country or carries more than two lines beside the town), which the schemes do not accept for a payment executed on or after their end date for that shape: the EPC's for SEPA, Swift's for a Danish cross-border payment. Submit a new payment with a structured address. The EPC and Swift have both postponed that end date (15 November 2026) and set no new one; while none is set, this reason is not raised. scheduled-format-change-error also ENDS it: the bank changed the payment file it accepts from a date that was reached between submission and release, and the approved file no longer meets the new rules (for example, more than one unstructured remittance line from Nordea Denmark's week 21 2027). Submit a new payment
approval.externalan approval decision was recorded from outside the normal approve/reject action
approval.quorum-unreachablethe approval policy can never be satisfied with the current approver set
approval.pending-overduea payment has been waiting for a 4-eyes approval signature longer than the alert window (default 2 days). Nobody has signed it, so nothing has been sent to the bank
security.cross-company-approveran approver attempted to act across a company boundary (a security event, not a normal flow)
security.oauth-refresh-reusea consumed OAuth refresh token was replayed outside the crash-recovery grace window. The token family and its grant are revoked, and the add-on must be re-paired (authorized again)
billing.allowance-warningthe payments billed this period, any sent before it included, crossed 80% or 100% of your package's included payments, then each further whole multiple (fraction, paymentsThisPeriod, includedPayments in data). Once per threshold per period; advisory, and sends are never blocked
billing.upgrade-recommendedthis billing period's volume already costs more on your current package than it would on the next package up (both costs in data). Once per billing period; we never change your package for you. Not sent in your first, shorter period, whose package price is waived
billing.tax-location-requiredVAT cannot be worked out from your registered address, so a renewal invoice was not issued or is charged without VAT (source in data). Complete the address under Company settings → Company. Saving it turns VAT back on where it was switched off
connection.bank-key-unresolveda connection references a bank key that no longer resolves
connection.no-payment-railsthe bank this connection was set up to offers no payment rails, so any payment to it is refused at conversion. Advisory, once at setup; an inbound-only connection is unaffected
payment.veu-cosignedan EBICS distributed-signature (VEU) co-signature was added to a payment
payment.veu-cosign-incompletean EBICS VEU payment still needs further co-signatures before it can be sent
payment.awaiting-signer-releasethe bank checked the payee (Verification of Payee) and holds the payment in its EBICS distributed-signature folder until your own signers release or cancel it there; it does not book until then
payment.signer-release-overduea payment still awaiting-signer-release reached an alert point before its bank deletes the unreleased file: half-way through the bank's period, three days before its end, then on the date and weekly. deletesAt in data; deletionPeriodSource says if the bank published it
payment.signer-decisionone of your own signers read the bank's payee-check result for a payment the bank holds and decided to release or cancel it (decision in data); recorded before the bank is asked, whose answer follows as payment.veu-cosigned, payment.cancelled or payment.veu-cosign-incomplete

Some internal transitions (e.g. webhook.auto-disabled) are audit-log only and are not fanned out as webhooks; don't wait on them. Poll GET /api/journal/list or GET /api/journal/events if you need to observe those.

Which statement alerts fire for camt.052 and camt.054. Most statement-level alerts run for every statement-shaped document we ingest, camt.052 (intraday report), camt.053 (statement), camt.054 (debit/credit notification) and MT940. Two are narrower, to avoid false alarms:

  • account.statement-gap is camt.053/MT940 only. A camt.052 is an _interim_ report numbered in its own intraday series, so its sequence numbers legitimately repeat and skip relative to the daily statement series. Comparing the two would report a missing page on almost every account.
  • payment.double-debit is camt.053/MT940 only. A camt.054 usually _notifies_ the very debit the day's camt.053 also books (one movement reported twice, by design). Counting both would report a double execution for entirely routine bank traffic. The companion alert statement.unmatched-our-reference has no such coupling and does fire for camt.052 and camt.054.

payment.double-debit also covers a batch-booked payment block. Some banks book a whole payment block as one debit that names the block's PmtInfId rather than each payment's EndToEndId. When the same block (same PmtInfId and total) is booked more times than you sent it, the event fires with referenceField set to paymentInfoId.

account.statement-integrity-gap runs for all four, but it can only reach a verdict when the document carries both a booked opening and a booked closing balance. A camt.054 carries none, and a camt.052 typically states an _interim_ balance (ITBD) rather than a closing one, so on those it stays silent rather than guessing.

Testing

POST /api/webhooks/<id>/test sends a webhook.test event through the real signing and delivery path. Use it to test your receiver end to end.


Part B: Polling

The designated integration poll is:

GET /api/journal/list?direction=outbound&uncollectedOnly=true&limit=500&cursor=<...>

Returns:

{
  "total": 1234,
  "uncollected": 12,
  "nextCursor": "<opaque>",       // null when you've reached the end
  "items": [
    {
      "journalNo": "...", "direction": "outbound", "type": "pain.001",
      "bankKey": "...", "summary": {...}, "status": "executed",
      "collected": false, "collectedAt": null,
      "createdAt": "...", "expiresAt": "..."
    }
  ]
}
  • uncollectedOnly=true returns only what you haven't collected yet, so you can drain new results without reprocessing. collected / collectedAt, the envelope's uncollected count and the uncollectedOnly filter all mean whether you have pulled the document, not whether we have it from the bank.
  • Page with cursor until nextCursor is null. /journal/list only accepts cursor (GET /api/journal/documents also accepts before).
  • If you omit limit, /journal/list defaults to 500 (capped at 1000).

For a single payment's current state, poll GET /api/journal/documents/<id>/payments (status, delivery mode, approval, recon). For reconciliation across pain.002 + camt.053, use GET /api/journal/reconciliation. For bank statements and detected gaps, use GET /api/accounts/statements, which pages newest period first: follow nextCursor until it is null.

Which reconciliation states are final

Each payment's reconciliation state (on GET /api/journal/reconciliation, GET /api/journal/documents/<id>/payments and GET /api/journal/payments/batch) is one of these. An ERP that locks a line while its payment is at the bank can release the line on every state marked final.

What it means for the paymentstateFinal?Details
PaidclearedYesA booked statement debit or the bank's status report says the bank executed it.
Not paid: the bank refused itrejectedYesevidence.reason carries the bank's reason code. Fix it and send a new payment.
Not paid: the bank cancelled itcancelledYesThe bank cancelled the payment.
Not paid as far as BankConnector knowsunconfirmedYes, until a late bank report moves it5 banking days after its requested execution date the bank has not reported it executed or refused. Check with the bank before paying again.
Not at the bank: never sent to itnot-sentOnly when status is rejected-by-approverevidence.summary says why. Waiting for approval, queued for delivery, or delivery failed: it is still moving.
Still movingpendingNoWaiting for the bank. "Needs review" in evidence.summary means bank evidence disagrees with the payment and a person must look.

unconfirmed in detail: a payment whose file reached the bank, or may have (journal status sent, sending, validated or partially-accepted), reads unconfirmed once 5 banking days have passed after its requested execution date (payments[].executionDate) with no bank status report or statement saying it was executed or refused. That includes a payment a partial status report accepted the file around without naming it. The 5 days start the day after the execution date, so a payment dated ahead is not counted early. Banking days skip weekends, bank holidays in the bank's country, and the closing days of the currency's settlement system (TARGET2 for EUR; Denmark, Sweden, Norway, the UK and Switzerland for DKK, SEK, NOK, GBP and CHF). Where BankConnector has no calendar for the country or currency, weekdays count. The day changes at 00:00 UTC, and evidence.date is the 5th banking day.

  • It is not a failure. The bank may have executed the payment without reporting it. Paying again on this state alone pays twice in that case.
  • It is not permanent. BankConnector works it out each time you read it, so a bank report that arrives later turns it into cleared, rejected or cancelled. Keep reading the state after you have released the line.
  • A payment held at the bank for your own signers to release (status: awaiting-signer-release) stays pending: your signers can still release it, which would pay it.
  • A batch's own state is unconfirmed when every payment in it is cleared or unconfirmed and at least one is unconfirmed.

Reading a document and its file

  • GET /api/journal/documents/<id> returns the parsed document as JSON. For an inbound bank file the raw XML is not in this response; fetch it from the route below.
  • GET /api/journal/documents/<id>/file returns the stored file: the pain.001 we generated for an outbound payment, or the bank's original camt.052 / camt.053 / camt.054, byte for byte. The raw statement file is kept for a bounded window (30 days by default) while the parsed statement is kept for the full retention period; after the window this route answers 404 raw_file_expired. Treat the JSON as the durable record.
  • GET /api/journal/documents/<id>/export.csv returns a camt.053 statement as a CSV for ERP import (bankconnector-statement-v1: UTF-8 with BOM, ; delimited, . decimal, ISO dates, one row per transaction, debits negative). Other document types answer 404.
  • GET /api/journal/documents/<id>/export.camt053 returns a camt.053 statement as an ISO 20022 camt.053.001.02 file, the version ERP statement importers read, whatever format the bank sent (camt.053.001.08, MT940). It is rendered from the parsed statement, so it still works after the raw file has expired, and the same document always gives the same bytes. Other document types answer 404.
  • Downloading a file, a CSV or a rendered statement does not mark the document collected.
  • A parsed pain.002 keeps the bank's rejection text even when the bank sends no ISO reason code (the reason's code is then absent), names the originator of a rejection when the bank does, and carries the bank's acctSvcrRef and stsId references when present.

Choosing (or combining)

WebhooksPolling
Latencysecondsyour interval
Complexitysignature verify + dedup + monitoringa loop + a cursor
Failure modesilent auto-disablenone (you drive it)

Use webhooks for real-time reaction, plus a poll every few minutes using uncollectedOnly=true as a backstop.

Polling counts against rate limits. GET /api/journal/list, GET /api/journal/documents, GET /api/journal/events and GET /api/journal/reconciliation share the 300/60 s per-caller bucket with POST /api/journal/payments, and every request counts against the per-IP cap of 600/60 s. Poll on an interval of seconds, not milliseconds, and honour Retry-After on reads too. → Rate limiting