Partner Credit Lines

DayZero keeps the credit ledger for a partner's card-backed lending product. The partner builds the merchant interface, executes every movement of money through its own processor, and owns the lending decision. DayZero recognises each posted card transaction, originates the loan, computes fees, interest, balances and payoff figures, allocates payments, closes the month, produces statements, posts the accounting, and pushes available credit back to the card issuer as a spend limit.

Nothing here moves money. Read every "payment" below as recording and calculation, never as a transfer.

Every amount is in integer cents. 4545000 = $45,450.00. Every figure the merchant sees is returned by the API; never derive balances, due dates, or statuses in the client.

Connecting

plaintext
https://api.ondayzero.com/api/v1/credit/...
Authorization: Bearer dz_your_token_here       # token type "api"
x-business-id: YOUR_BUSINESS_ID                # the operator (lender) business
Content-Type: application/json

A token minted for exactly one business may leave out x-business-id; that business is used. When the header is sent it wins, and one outside the token's businesses is 403. A token for several businesses, or none in particular, must send it (401 otherwise).

Before a test run, check which book you are on: GET /credit/environment returns { "business_id", "sandbox", "sandbox_business_id", "live_business_id", "programs": [{ "id", "display_name", "issuing_enabled", "issuing_provider", "issuing_provider_mode" }] }. sandbox is true on a sandbox business, and issuing_provider_mode is mock for a program whose cards come from DayZero's in-memory test issuer and live otherwise. Assert on both before you push money-shaped data. On a live business with a paired sandbox, sandbox_business_id names it; on a provisioned sandbox, live_business_id names the live business it rehearses (both are null otherwise). See Sandbox.

Tokens are issued under Settings › Developers, and they need two-factor authentication on the DayZero account that owns them: minting or rotating one needs a verified second factor, and a token stops working (403, AUTH_085) if its owner later removes their second factor. See API tokens. The credit surface sits behind the CFO Suite add-on and returns 403 (BILLING_003) until it is enabled for the business; the partner_program plan includes it (see Billing and token access).

Every response is wrapped: { "success": true, "message": null, "code": null, "data": { ... } }. Unwrap data before use.

Status Meaning Handling
400 Invalid payload, unknown filter value Correct the request
401 AUTH_003: an expired token, or an MCP OAuth token used against REST (the two are indistinguishable in the response). AUTH_004: a revoked or unknown token. AUTH_059: a token replaced by POST /api/v1/tokens/{id}/rotate whose overlap window has ended Do not retry. Use a current API token from Settings › Developers
403 Add-on not enabled for the business; a credit-scoped token outside /api/v1/credit and its webhook endpoints; an API token on an operator action (below); a token whose owner has removed their second factor (AUTH_085) Escalate; not retryable. For AUTH_085, the owner turns two-factor back on and creates a new key
404 No such line, loan, payment, or assessment, or an id in the path that is not a UUID (it is answered exactly as an unknown id is, never as a server error). For a line, this is how the "no credit line yet" state is detected Check the id
409 Idempotency conflict; a rule the program has not decided (CREDIT_POLICY_UNDECIDED), such as an underwriting assessment requested with no scorecard to measure it against See the call's notes
429 Over a rate limit (see Rate limits): GEN_005 for a per-business endpoint limit (POST /credit/card-settlements and POST /credit/payments 600 a minute, POST /credit/interchange/report 120 a minute), AUTH_058 for the token's own per-minute budget Wait Retry-After seconds, then retry
5xx Transient Retry with backoff (below)

Every credit refusal carries a machine-readable code beside the message. Match on code: the message text may change between releases, the codes are stable. Per-section tables below repeat the codes that section raises.

code Status Meaning
CREDIT_001 400 period is not a calendar month written YYYY-MM
CREDIT_002 400 QuickBooks is not connected, so the credit journal cannot be pushed
CREDIT_003 400 The lines sit under more than one program; a borrowing base needs one program_id
CREDIT_004 400 term_days is not one of the program's offered terms (disclosure)
CREDIT_030 400 A line status change, or taking over a suspension collections holds, needs a reason
CREDIT_031 403 Line administration (closing a line, changing its limit) is console-only on this program; an API token cannot do it
CREDIT_061 400 A limit-review policy's floor or cap is outside the program envelope
CREDIT_062 400 apply: direct on a program whose limit_change_policy is not direct
CREDIT_063 400 A limit-review trigger names a measure DayZero does not have
CREDIT_064 400 A review was asked for on a program with no enabled policy, or a line that is not active
CREDIT_065 409 The limit review is not proposed
CREDIT_066 409 The line's limit changed since the review ran
CREDIT_067 404 No such limit review
CREDIT_068 400 A limit-change request on a line with no program; change its limit with PATCH
CREDIT_069 403 An API token cancelled a limit-change request it did not make
CREDIT_070 403 A read-only token asked for the autopay, cards or acknowledge portal scope
CREDIT_071 404 No such portal session on this business
CREDIT_072 400 Schedule requested for a cycle_draw line
CREDIT_073 403 (page) The link's scopes do not include that page or action
CREDIT_074 403 (page) The form's CSRF token is missing or does not match
CREDIT_075 400 (page) AutoPay needs a linked bank account and none is linked
CREDIT_076 400 (page) The card is lender-managed and cannot be frozen or unfrozen there
CREDIT_077 400 (page) The lender suspended the card; only the lender can release it
CREDIT_078 400 (page) A card unfreeze on a line that cannot spend, or AutoPay switched on for a closed line that owes nothing
CREDIT_079 400 (page) The payoff date is outside the allowed range
CREDIT_090 400 The sandbox API was called on a business that is not a sandbox
CREDIT_091 400 A sandbox reset was sent without confirm: "RESET"
CREDIT_101 400 Unknown webhook event type
CREDIT_102 400 The webhook endpoint is inactive
CREDIT_103 403 A credit-scoped token managing a webhook endpoint that subscribes outside credit events
CREDIT_201 400 The loan cannot be disputed: it was written off, or earlier disputes already reversed all of its principal
CREDIT_202 400 The loan already has an open dispute under a different provider_dispute_id
CREDIT_203 400 amount_cents is more than remains to dispute on the transaction
CREDIT_204 400 The dispute was already resolved with a different outcome
CREDIT_205 400 opened_on is before the transaction or on or before a payment applied to the loan, or resolved_on is before opened_on
CREDIT_206 400 provider_dispute_id is already recorded against another loan
CREDIT_207 400 The loan is on hold; resolve the dispute first (write-off)
CREDIT_208 404 No such dispute
CREDIT_301 404 No such payment match on this business
CREDIT_302 400 The match was already confirmed, rejected or superseded
CREDIT_303 400 The match already settled or reversed its payment; reverse the payment instead
CREDIT_304 400 payment_id is required: the match names no single payment
CREDIT_305 400 The payment is on another line
CREDIT_306 400 The payment's amount is not the bank amount
CREDIT_307 400 The payment is not pending (to settle) or not applied (to reverse)
CREDIT_308 404 No such sweep run
CREDIT_309 400 The sweep run has not finished; re-run a step once it has
CREDIT_310 400 cursor is not a cursor this list issued (matches, sweep runs)
CREDIT_401 400 An adjustment is larger than what is outstanding in the bucket it reduces
CREDIT_402 400 The loan is paid or written off; there is nothing to adjust
CREDIT_403 400 An adjustment's effective_on is before the loan was originated, or a goodwill credit is dated before a payment already applied to the loan
CREDIT_404 400 A cursor the list did not return (loans, payments, disputes, limit reviews, portal sessions, notices, adjustments)
CREDIT_500 400 The program does not have card issuing enabled
CREDIT_501 400 cardholder_provider_user_id is required to issue a card
CREDIT_502 400 The card issuer refused the request; the message carries the issuer's reason
CREDIT_503 400 The card is terminated and cannot be modified or suspended
CREDIT_504 400 No card has been minted for this record yet
CREDIT_505 400 The card has no provider fund; issuing did not complete
CREDIT_506 400 The card is not active, so its credentials cannot be displayed
CREDIT_507 400 That issuer card is already registered on a different credit line
CREDIT_508 400 A dispute list status filter is not open or resolved
CREDIT_509 400 A clock date (as_of) is in the future on a live business
CREDIT_510 400 A money date (paid_on, opened_on, transaction date, a loan import's as_of) is in the future on a live business
CREDIT_511 400 A program's limit-review policy does not validate
CREDIT_512 400 credit_limit_cents is outside the FRD or program limit envelope
CREDIT_513 400 term_days is not an allowed term (FRD 30, 45, 60, 90, or the program's terms)
CREDIT_514 400 rate_bps is outside 0 to 10000
CREDIT_515 400 disbursement_fee_bps is outside 0 to 500
CREDIT_516 400 Neither credit_line_id nor external_line_id was sent
CREDIT_517 400 No statement exists for an open period; it is produced at month-end close
CREDIT_518 400 allowed_term_days is empty
CREDIT_519 400 allowed_term_days holds a term outside the FRD options
CREDIT_520 400 limit_min_cents is more than limit_max_cents
CREDIT_521 400 The program's limit envelope is outside the FRD range
CREDIT_522 400 A program with that slug already exists
CREDIT_523 400 A program rule (interest, fee, policy configuration) does not validate; the message names the rule
CREDIT_524 400 The program's status does not allow opening new lines
CREDIT_525 400 The operation applies to per-transaction programs only (loan import, pending payments, sandbox scenario)
CREDIT_526 400 date_basis is not transaction or due
CREDIT_527 400 A loan status filter names an unknown status
CREDIT_528 400 The payment is below the loan's minimum payment
CREDIT_529 400 loan_id is not an open loan on this line
CREDIT_530 400 The loan is already written off
CREDIT_531 400 A paid loan has nothing to write off
CREDIT_532 400 A write-off amount_cents is not positive
CREDIT_533 400 A partial write-off is more than the outstanding principal
CREDIT_534 400 The payment is already reversed
CREDIT_535 400 A reversed payment cannot be settled
CREDIT_536 400 A payment alias is empty once punctuation and company suffixes are removed
CREDIT_537 400 The alias already resolves to another credit line
CREDIT_538 400 A sandbox scenario as_of is before the scenario's payment date
CREDIT_539 400 A manual-check attestation status is not recognised
CREDIT_540 400 The manual check does not apply to this application, so there is nothing to attest
CREDIT_541 400 The manual check requires evidence; attach a file before attesting
CREDIT_542 400 The assessment's scorecard has no manual check with that id
CREDIT_543 400 The scorecard version cannot be scored yet; the message carries the validator's reason
CREDIT_544 400 The scorecard version no longer validates and has gone back to draft
CREDIT_545 400 A scorecard or criteria_override definition does not validate
CREDIT_546 400 scorecard_version_id or copy_from_version_id belongs to a different program
CREDIT_547 400 The scorecard version is not approved yet, so it cannot back an assessment
CREDIT_548 400 A backtest was sent with no businesses
CREDIT_549 400 A backtest covers more businesses than the maximum
CREDIT_550 400 A backtest is missing requested_limit_cents (fixed mode) or requested_limits_cents (per-business mode)
CREDIT_551 400 A line was opened with card under a program whose cards DayZero does not issue (card_management: partner, or issuing_enabled false, or no program); no line is opened
CREDIT_556 409 An assessment was completed twice; only a pending assessment can be completed (the response names assessment_status)
CREDIT_560 400 A program update set a required field to null; leave the field out to keep its value
CREDIT_561 400 A program update changed card_management while the program has active or suspended cards; use POST /credit/programs/{id}/card-management
CREDIT_562 400 after was sent with order=desc on the event feed; page older with before
CREDIT_563 400 before was sent with order=asc on the event feed; page forward with after
CREDIT_564 400 A program update changed lending_model while the program has a credit line; create a new program for the other model
CREDIT_565 400 sync-config would change the lending_model of a sandbox program that has lines; reset the sandbox and sync again
CREDIT_580 400 Sandbox provisioning or sync-config was called on a sandbox; call it on the live business
CREDIT_581 400 sync-config on a live business that has no paired sandbox yet
CREDIT_582 400 The business paired as the sandbox is no longer marked as one, so nothing is copied to it
CREDIT_583 400 (staff) plan_tier partner_program without a partner_agreement_ref of 1 to 255 characters
CREDIT_585 400 (staff) A billing change would take the CFO Suite add-on away from a business that operates credit; keep it in the same change
CREDIT_584 403 mint_token: true on sandbox provisioning from DayZero staff acting by elevation; a member of the business mints the sandbox token
CREDIT_590 400 An MCC allow-list preview request does not validate (source, window_days, preset, min_count, amounts_in)
CREDIT_591 400 A multipart allow-list preview has no file part
CREDIT_592 400 The uploaded card export could not be read: not UTF-8, not CSV or JSON, no MCC column, past a cap (500 columns, 10,000 characters in a cell, 2,000,000 cells), or an amount no ledger can hold; the message names the row
CREDIT_593 400 The uploaded card export is larger than 10 MB, or has more than 200,000 rows
CREDIT_594 400 An allow-list code is not four digits
CREDIT_595 400 An allow list was applied with no codes and without unrestricted: true
CREDIT_596 400 unrestricted: true was sent with codes
CREDIT_597 400 None of the allow-list codes maps to a Ramp category, so the issuer would allow everything
CREDIT_598 400 file_sha256 is missing or malformed with source: upload, or sent with another source
CREDIT_599 400 An allow-list preview was sent as neither multipart/form-data nor application/json
CREDIT_600 400 A compliance-enabled program needs merchant_legal_name, merchant_state and merchant_address on the line (at open, and they cannot be cleared); the message names what is missing
CREDIT_601 400 merchant_state is not a two-letter US state, DC or territory code
CREDIT_602 400 No approved template for a document the policy calls for (line open, limit increase, generating a disclosure, sending a notice); the message names the template to approve
CREDIT_603 400 The approved template's file changed after it was approved
CREDIT_604 404 No such compliance template, or its file is no longer shipped
CREDIT_605 400 The template's status does not allow it: only a draft can be approved, and a retired template cannot be retired again
CREDIT_606 400 Approving a template needs reviewed_by, reviewed_on and signoff_document
CREDIT_607 400 Generating a disclosure on a line whose program has no compliance policy on, or whose disclosure mode is off
CREDIT_608 404 No such disclosure
CREDIT_609 400 (page) The disclosure was replaced by a newer one
CREDIT_610 400 (page) The disclosure does not need an acknowledgement; or withdrawing a disclosure that is not a held increase awaiting acknowledgement
CREDIT_611 404 No such adverse action
CREDIT_612 400 The adverse action is not pending (sending or waiving one already waived or found not required, or waiving one already sent)
CREDIT_613 400 Waiving a notice, or recording that none is required, needs a reason
CREDIT_614 400 There is no one to send the notice to: no credit line, or no merchant_email on it
CREDIT_615 400 No longer returned: the notice email is sent after the request commits, and a failed one puts the notice back to pending
CREDIT_616 400 No document yet: a disclosure that was not required, or a notice not yet sent
CREDIT_617 400 Activating a pending line whose disclosure awaits the merchant's acknowledgement; an operator may send acknowledgement_override
CREDIT_618 403 acknowledgement_override from an API token; only an operator in the console can activate a line without the acknowledgement
CREDIT_619 400 (page) The held limit increase can no longer be applied (the program's limit envelope narrowed past it); nothing was recorded
CREDIT_620 400 A loan import file is larger than 10 MB
CREDIT_621 400 A loan import file has more than 20,000 rows
CREDIT_622 400 A loan import file cannot be read as CSV or XLSX, has no header row, has no rows, or is past a cap (500 columns, 10,000 characters in a cell, 2,000,000 cells, more empty rows than a worksheet holds, a workbook that unpacks past 100 MB)
CREDIT_623 400 A loan import mapping does not fit the file, or amounts_in is not dollars or cents
CREDIT_624 400 No saved or built-in loan import mapping has that mapping_name
CREDIT_625 404 No such import batch
CREDIT_626 400 The import batch expired; upload the file again
CREDIT_627 400 The import batch was staged for another program
CREDIT_628 400 The import batch was applied, so its mapping cannot change
CREDIT_629 400 create_missing_lines was sent without line_defaults
CREDIT_630 400 The import mapping names no line key (external_line_id or external_merchant_id)
CREDIT_631 400 The import batch has no report yet; dry-run or apply it first
CREDIT_632 400 save_as names a built-in import mapping
CREDIT_633 400 The import batch was applied and cannot run again; upload the file again
CREDIT_634 400 mode: sync on a loan import file of more than 2,000 rows; send async or auto
CREDIT_635 400 A run of this import batch is in progress (or its job stopped reporting but its worker still holds the program)
CREDIT_636 400 Another loan file is running on the program; one runs at a time
CREDIT_640 400 A statement view is not issued or current
CREDIT_641 400 view=current was asked of a printable statement (pdf, html), which is always the issued one
CREDIT_646 409 A redelivery was asked of a delivery that is being sent at this moment; read it again once the attempt has finished and redeliver it then if it failed
CREDIT_650 400 A backdated payment was refused: an adjustment dated after it could no longer apply (the message names it)
CREDIT_651 400 A backdated payment was refused: a dispute resolution dated after it was recorded before DayZero kept how its fee reversal split, on a program with deferred fee recognition
CREDIT_652 400 A dispute's opened_on, an adjustment's effective_on or a write-off's written_off_on is before the loan was imported; the message gives the earliest date allowed
CREDIT_653 400 A backdated payment was refused: a partial write-off dated after it could no longer apply (the message names it)
CREDIT_660 400 A live program cannot be made active while a blocking launch check fails; the message names the checks
CREDIT_661 403 force_activate was sent with an API token; only an operator in the console can force activation
CREDIT_662 400 force_activate was sent without a reason
CREDIT_670 400 A partner-export file is larger than the console accepts
CREDIT_671 400 A partner-export file has more rows than the console accepts
CREDIT_672 400 A partner-export file, the mapping, or a value in a file could not be read, or a file is past a cap (500 columns, 10,000 characters in a cell, 2,000,000 cells); the message names the file and row
CREDIT_673 400 The export holds un-redacted emails, phone numbers, tax ids, card or bank account numbers, or street addresses; applying it needs allow_unredacted with a reason
CREDIT_674 400 allow_unredacted was sent without a reason
CREDIT_675 400 The transactions file has no card transaction that can be seeded
CREDIT_676 400 The export is too large to apply from the console; seed it with the script
CREDIT_677 400 The program already holds the portfolio scenario for that seed
CREDIT_678 400 Start from a seed: the program's scorecard already has a different open draft
CREDIT_679 400 The scorecard seed does not import
CREDIT_680 400 A pull's manifest is not valid, or does not match the files sent with it
CREDIT_681 400 A pulled file's SHA-256 is not the one its manifest records
CREDIT_682 400 roles is not role-to-table JSON, names an unknown role, names a table the pull does not have, or gives one table two roles
CREDIT_683 400 The pull expired (30 days) and its files were removed
CREDIT_684 400 A partner export must send files or a pull_id, not both and not neither
CREDIT_685 400 The pull has no table recorded as the transactions file; send roles
CREDIT_686 400 A merchant in the partner export is already a line on another program of the business
CREDIT_690 400 A loan import apply carried a dry_run_token that is not the batch's last dry run, or its inputs or the mapping differ from that dry run's
CREDIT_700 503 The card issuer answered a Card Info request without a token, so there is nothing to display; nothing was recorded as disclosed. Try again shortly
CREDIT_710 400 A sandbox card transaction names a card that is not on the mock issuer
CREDIT_711 400 A sandbox void or dispute names an issuer transaction id the book does not hold
CREDIT_720 400 A program's collections_policy is not a ladder the nightly sweep can run; the message names each bad stage
CREDIT_730 400 A payoff quote on a per-transaction line is dated before today and before the day the line's book is already processed to; the message gives the earliest date it will quote
CREDIT_731 400 A settlement call gives a settled payment a different settled_on from the one it settled on

Timeouts and retries

Requests complete well inside 30 seconds; treat anything longer as a timeout. A 429 was refused before anything ran (see Rate limits): retry it after Retry-After. After a timeout, a dropped connection or a 5xx the request may have been applied, so retry it with exponential backoff (the first attempt and then five retries, six attempts in all, waiting 1s, 2s, 4s, 8s and 16s before each retry) only when a repeat is harmless: a read; a write deduplicated on a key you send (lines opened on external_line_id, settlements on external_settlement_id, payments on external_payment_id, loans imported onto a line on each loan's external_settlement_id, interchange rows on external_reference, card issue on idempotency_key, card registration on provider_card_id, payment aliases on alias, assessment requests on external_application_id; these keys are optional on each call, but a call sent without its key is not deduplicated, so send it on every write you might retry); or a write that sets a value outright, such as a card's limits (PATCH /credit/cards/{id}/limits), suspending a card, lifting its suspension or terminating it, or a webhook endpoint's settings (PATCH /webhooks/o2c/{id}). A keyed repeat returns what the first call made and writes nothing, also when it arrives while the first is still running: it waits for the first to finish and answers with its result. Other writes are not harmless to repeat: opening a line without external_line_id or issuing a card without idempotency_key (each call makes another), changing a line (a limit increase the program holds for acknowledgement issues a new disclosure each time), completing an assessment (a second completion is 409), registering a webhook endpoint, rotating its secret or a token, test-firing and redelivering. After a timeout on one of those, read what it changes before sending it again. The SDKs apply exactly this rule to every operation (see SDKs). Show the merchant the last figures you have with their as_of timestamp rather than zeroes while a call is failing.

Reference, SDKs, and Postman

  • API reference: https://api.ondayzero.com/api/v1/credit/docs renders every endpoint, request body, and response. No token is needed to read it.
  • OpenAPI spec: GET /api/v1/credit/openapi.json (also committed as docs/api/credit-openapi.json). It holds only the credit, webhook-registry, and token endpoints. Generate your own client from it if you prefer.
  • SDKs: sdk/typescript (published as @dayzero-eng/credit-sdk) and sdk/python (dayzero-credit); see SDKs.
  • Postman: docs/api/credit.postman_collection.json. Set the baseUrl, token, and businessId collection variables.

SDKs

Both SDKs have one function for every operation in the spec, generated from it: the TypeScript one by Orval, the Python one by scripts/generate_credit_python_sdk.py, each named after the part of the operation's operationId before _api_v1_ (list_credit_lines_api_v1_credit_lines_get is listCreditLines in TypeScript, list_credit_lines in Python). A contract test, run before every release, fails when an operation in the spec has no function in either, or a write has no retry rule. Both set the auth headers, unwrap the response envelope, raise an error carrying the response's code and request id, hand back file downloads unparsed (Python: bytes; TypeScript: a Blob for a PDF, text for a CSV), and verify webhook signatures. They retry with the backoff above (or after Retry-After when the response sets it) as Timeouts and retries describes: a 429 on every call, and a timeout, a dropped connection or a 5xx only on a read, a write that sets a value outright, or a write that carries its deduplication key. Each operation's rule, read from the server's handlers, is in the SDK (OPERATIONS[name].retry in Python, OPERATION_RETRY in TypeScript); the Python SDK also retries a connection that could not be opened, which sent nothing. Any other write is sent once and, with no response, raises CreditOutcomeUnknownError naming the read that tells whether it was applied. A retry option (always, never) overrides the rule for a client or a single call. The Python SDK also walks paged lists for you, newest first included.

An SDK's version is the API version it was built from, written as a calendar version without leading zeros (a version dated 7 March 2027 would be SDK 2027.3.7). Today both install from a checkout of the repository (pip install ./sdk/python; the TypeScript package from sdk/typescript, for example packed with pnpm pack). Once DayZero tags a release (credit-sdk-v<version>) they are also published privately: the TypeScript package on GitHub Packages as @dayzero-eng/credit-sdk, the Python wheel on the matching GitHub Release. Installation, authentication and examples are in sdk/typescript/README.md and sdk/python/README.md; ask us for access or for the files.

Compatibility

Changes inside /api/v1 are additive. A field is never removed or renamed, and a value never changes meaning, without a dated deprecation in docs/api/CHANGELOG.md at least 90 days before the change ships. New endpoints, new optional request fields, new response fields, new enum values on response fields, and new event types are additive; ignore any field or event type you do not recognise. Every response from /api/v1/credit, errors included, carries X-DayZero-API-Version with the contract version, a date. That header, info.version of the spec and the newest entry in docs/api/CHANGELOG.md always name the current one; log it with each figure you store.

Browser code on an origin we allow can read these response headers: X-DayZero-API-Version, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Total-Count and X-Request-Id. They are listed in Access-Control-Expose-Headers; without that a browser hides every response header but a handful of standard ones. Server-side callers always see all of them.

Rate limits

Three calls are limited per business, over a rolling window:

Call Limit
POST /credit/card-settlements 600 per 60 seconds
POST /credit/payments 600 per 60 seconds
POST /credit/interchange/report 120 per 60 seconds

Every response from these calls carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the oldest counted call leaves the window). A call over the limit is 429 with code: "GEN_005" and Retry-After set to the same number of seconds:

plaintext
HTTP/1.1 429
Retry-After: 42
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 42

{ "error": "rate_limited", "message": "...", "code": "GEN_005" }

A refused call is not processed, so retrying it after the wait is safe. Batch settlements (up to 1,000 per call) rather than sending one at a time.

An API token also has its own request budget per minute across every call it makes (the token's rate_limit_per_minute, or the platform default). A call over it is 429 with code: "AUTH_058" and the same Retry-After and X-RateLimit-* headers, Retry-After being the whole seconds until the oldest counted call leaves the window.

Operator actions

Operator actions are performed in the DayZero console by a signed-in person, not calls your product makes; an API token receives 403 on each of them, whoever created it, unless this section names an exception. Tell us and we run them. Creating and changing a program (POST /credit/programs, PATCH /credit/programs/{id}), importing loans onto a line (POST /credit/lines/{id}/loans/import), writing off and adjusting a loan (POST /credit/loans/{loan_id}/write-off, POST /credit/loans/{loan_id}/adjustments) and pushing the journal (POST /credit/finance/journal/push) are operator actions.

The operator actions that move money or change terms also need a two-factor step-up: the operator's DayZero account must have a second factor, verified in the current session, and a first factor verified in the last 10 minutes. Without a second factor the call is 403 AUTH_050; with a stale check, 403 AUTH_056. The console answers both by asking for the factors and sending the action again, so an operator is never simply refused. They are: creating and changing a program, importing loans (onto a line, and applying a loan file, POST /credit/programs/{id}/loans/import), writing off and adjusting a loan, deciding a limit-change request, applying an MCC allow list, switching who manages a program's cards, approving and activating a scorecard version, making a scorecard the program's default (PATCH /credit/scorecards/{id} with is_default, which changes the rules new assessments are measured against as activating a version does), lowering a scorecard's required_approvals (the same call; it removes a sign-off; renaming a scorecard or raising required_approvals asks for no step-up), and provisioning the paired sandbox when it mints a token. The other operator actions, reading an import batch or report, reading billing lapse risk, and listing or previewing templates among them, need a signed-in operator and the firm's own two-factor policy only.

Deciding a limit review is an operator action unless the program's limit_change_policy is direct; see Limit reviews. Deciding a limit-change request (POST /credit/limit-change-requests/{id}/decision) is an operator action under every policy; asking for one, reading them and cancelling your own are open to your token. Under approval_required no caller changes a limit with PATCH, and under console_only your token cannot; see Limit changes.

Deciding a bank-settlement match (POST /credit/payment-matches/{id}/override) and re-running a step of the daily sweep (POST /credit/sweeps/runs/{id}/rerun-step) are operator actions as well; reading the matches and the run log is open to your token. See Bank-matched settlement and What runs on DayZero's schedule.

A program's card setup is operator-only as well: previewing and applying its MCC allow list (POST /credit/programs/{id}/mcc-allowlist/preview and POST /credit/programs/{id}/mcc-allowlist/apply, see MCC allow list) and switching who manages its cards (POST /credit/programs/{id}/card-management, see Switching who manages the cards).

Provisioning the paired sandbox (POST /credit/sandbox/provision), copying configuration to it (POST /credit/sandbox/sync-config) and reading whether API access is about to lapse (GET /credit/billing-lapse-risk) are operator actions on the live business; see Sandbox and API tokens.

The compliance registry and its records are operator actions as well: listing, previewing, approving and retiring templates (GET /credit/compliance/templates, GET /credit/compliance/templates/{id}/preview, POST /credit/compliance/templates/{id}/approve, POST /credit/compliance/templates/{id}/retire), generating a disclosure by hand (POST /credit/lines/{id}/disclosures), withdrawing a limit increase a disclosure holds (POST /credit/disclosures/{id}/withdraw), and sending or waiving an adverse-action notice (POST /credit/adverse-actions/{id}/send, POST /credit/adverse-actions/{id}/waive). Reading a line's disclosures, downloading them, and reading the adverse-action queue are open to your token. See Compliance.

The loan migration wizard is operator-only from end to end: uploading a file (POST /credit/programs/{id}/loans/import/parse), correcting its mapping (PUT /credit/import-batches/{id}/mapping), the dry run and the apply (POST /credit/programs/{id}/loans/import), and reading batches and reports (GET /credit/import-batches/{id}, GET /credit/import-batches/{id}/report, GET /credit/programs/{id}/import-batches). See Launching with existing loans.

Storing a launch readiness evaluation (POST /credit/programs/{id}/readiness/evaluate) and forcing a program active (force_activate) are operator actions; reading readiness is open to your token. See Launch readiness.

Changing a scorecard is an operator action too: creating one (POST /credit/programs/{id}/scorecards, or from a seed with POST /credit/programs/{id}/scorecards/from-seed), changing its settings (PATCH /credit/scorecards/{id}), opening a draft (POST /credit/scorecards/{id}/versions), editing or discarding a draft (PATCH /credit/scorecard-versions/{id}, DELETE /credit/scorecard-versions/{id}), recording an approval (POST /credit/scorecard-versions/{id}/approve), activating a version (POST /credit/scorecard-versions/{id}/activate), and running a backtest (POST /credit/scorecard-versions/{id}/backtest) all return 403 to an API token. Reading scorecards and previewing a version against a business are open to any token; validating a version stores a report and can move a draft to validated, so it needs a token with the credit scope (a credit:read token, or a viewer in the console, gets 403). See The scorecard.

The four sweep endpoints — POST /credit/cycles/close, POST /credit/cycles/assess-late, POST /credit/accruals/run and POST /credit/sweeps/daily — are operator actions on a live business: an API token receives 403, and a signed-in person must pass the firm's two-factor check. On a sandbox business anyone with access, API token included, may run them; that is how a partner runs the clock forward. See Sandbox.

Completing an underwriting assessment by hand (POST /credit/underwriting/assessments/{id}/completion, which scores figures the caller supplies) and measuring one at once without waiting for the merchant's data (POST /credit/underwriting/assessments/{id}/run) follow the same rule: open to any token on a sandbox business, which is how the sandbox validator walks an application; on a live business an operator action, so an API token receives 403 (AUTH_057) and a signed-in person must pass the firm's two-factor check. A live merchant's assessment is completed by the nightly sweep once the data is in, or by an operator. See Underwriting.

The /api/v1/loans/ endpoints are a different product — a company's own borrowings (the business-loan subledger) — and are not part of this API.

Pagination

Every list under /credit answers with the same envelope, { items, next_cursor, has_next, limit }, with two documented exceptions below. Pass next_cursor back as cursor (as after on the event feed and the ledger, whose cursors are sequence numbers). has_next looks one row past the page, so a full last page is not "more", and next_cursor is null on the last page. Treat a cursor as an opaque string.

Endpoint Default Max Paging include_total_count
GET /credit/lines/{id}/loans 100 1,000 cursor (date and id) yes
GET /credit/lines/{id}/payments 50 500 cursor (date and id) yes
GET /credit/lines/{id}/subledger (loan rows) 200 1,000 cursor no
GET /credit/events, GET /credit/event-deliveries 100 500 after (sequence number), or before with order=desc no
GET /credit/lines/{id}/ledger 500 5,000 after (sequence number) no
GET /credit/lines/{id}/cards 100 500 cursor (offset) yes
GET /credit/lines/{id}/payment-aliases 100 500 cursor (offset) yes
GET /credit/underwriting/assessments 100 500 cursor (offset) yes
GET /credit/underwriting/measurers 100 500 cursor (offset) no
GET /credit/underwriting/subject-businesses 100 500 cursor (offset) no
GET /credit/programs/{id}/scorecards 100 500 cursor (offset) no
GET /credit/scorecards/{id}/versions 100 500 cursor (offset) no
GET /credit/scorecards/{id}/audit 200 500 cursor (offset) no
GET /credit/underwriting/assessments/{id}/audit 200 500 cursor (offset) no
GET /credit/scorecard-versions/{id}/backtests 20 100 cursor (offset) no
GET /credit/lines/{id}/portal-sessions 50 200 cursor (offset) no
GET /credit/lines/{id}/disputes 50 500 cursor (offset) no
GET /credit/lines/{id}/limit-reviews 50 500 cursor (offset) no
GET /credit/lines/{id}/limit-change-requests 50 500 cursor (request id) no
GET /credit/lines/{id}/payment-matches 50 500 cursor (offset) no
GET /credit/sweeps/runs 14 100 cursor (offset) no
GET /credit/lines/{id}/notices 50 500 cursor (offset) no
GET /credit/loans/{loan_id}/adjustments 50 500 cursor (offset) no
GET /credit/lines/{id}/credit-balance 50 500 cursor (offset) no
GET /credit/lines/{id}/disclosures 50 500 cursor (offset) no
GET /credit/adverse-actions 50 500 cursor (offset) no
GET /credit/programs/{id}/import-batches 20 100 cursor (offset) no
GET /credit/readiness 20 100 cursor (offset) no
GET /credit/programs, GET /credit/lines 100 500 offset / limit lines only, as a header
GET /webhooks/o2c, GET /webhooks/o2c/{id}/deliveries 50 100 cursor no

An offset cursor is the position of the next row. On a newest-first list that gains rows while you page, a row can repeat or be skipped; these lists change a few times a day at most. The loans, payments, events, ledger and limit-change request cursors are keys, and are exact.

Every paged list returns items, next_cursor, has_next, and limit. Pass next_cursor back as cursor (or after on the event feed and ledger). has_next looks one row past the page, so a full last page is not "more". The loan page inside the subledger response carries the same cursor under loans, loans_next_cursor, and loans_has_next.

total is present only when you pass include_total_count=true, because it costs a count query, and then it is present on every list that offers it: loans, payments, underwriting assessments, cards and payment aliases (the rows marked "yes" above). GET /credit/lines answers the same flag with an X-Total-Count header instead, its body being an array. It counts every row matching the filters, not the page. A list that does not offer it never carries a total field (the event feed and ledger have none: a count over a gapless sequence says nothing).

Exceptions. The event feed also returns its page as events and has_more, the names it had before the envelope was shared; they are the same values as items and has_next and are kept for existing clients. GET /credit/programs and GET /credit/lines are offset/limit and return a JSON array, not an envelope, and stay that way; on GET /credit/lines, include_total_count=true returns the matching count in an X-Total-Count response header. For a merchant dashboard, call GET /credit/lines/{id} rather than listing every line; to find a line by your own id, filter the list with external_line_id or external_merchant_id (see Open a credit line).

Lending models

A program is one partner's configuration: the parameter envelope, the policy positions, statement branding, GL posting, and card issuing. Lines open under a program and inherit from it. The program's lending_model chooses how credit is billed:

Model Unit of borrowing Interest
per_transaction Every posted card transaction is its own loan with its own term, due date, and disbursement fee Accrues daily on remaining principal from the day after the loan's due date, applied to the balance at each month-end close
cycle_draw Settlements accumulate into a monthly cycle that resolves into one draw at close Computed once at close for the full term

The rest of this guide describes per_transaction, which is the default for new programs.

A program's lending_model is fixed once any line has been opened under it, in any status: the two models keep different books (loans, or cycle draws), and a line's book cannot move from one to the other. A PATCH that changes it then is refused with 400 CREDIT_564; create a new program with the other model and open new lines under it. Sending the model the program already has is not a change. A program with no lines yet may still switch.

bash
curl -X POST https://api.ondayzero.com/api/v1/credit/programs \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "tie",
    "display_name": "Tie Capital",
    "status": "active",
    "lending_model": "per_transaction",
    "allowed_term_days": [30, 45, 60, 90],
    "default_rate_bps": 2000,
    "default_disbursement_fee_bps": 100,
    "allocation_order": "interest_fee_principal",
    "loan_ordering": "oldest_first",
    "minimum_payment_cents": 2500,
    "due_soon_days": 3,
    "early_payoff_terms": {"interest_rebate": "none", "fee_rebate": "none"},
    "legal_lending_entity": "Revenue Roll Lending dba Tie Capital",
    "statement_branding": {"accent_hex": "#5B21B6", "footer_text": "Questions? support@example.com"},
    "covenants": {"cash_covenant_bps": 5000}
  }'

Program parameters

Field Meaning
status draft, active, paused or retired. Lines open under draft and active programs. On a live business, a PATCH that moves a program into active is refused (CREDIT_660) while a blocking launch check fails
force_activate PATCH only, operator only: {"reason": "..."} makes the program active although blocking launch checks fail. Recorded on the program as activation_override (read-only: who, when, why and the failing checks) and in the audit log
allowed_term_days Payback terms a line may select, chosen from 30, 45, 60 and 90 days (FRD §10.1); a program narrows that list, it cannot add to it. Applied per loan from its posting date
limit_min_cents, limit_max_cents The envelope a line's limit must sit in. Both inside $10,000–$1,000,000 (FRD §10.1); a program narrows the range, it cannot widen it
default_rate_bps Annual rate. 2000 = 20% APR, applied as a daily rate of rate ÷ 365
default_disbursement_fee_bps Fee assessed once at origination on each transaction. Overridable per line
allocation_order Bucket order a payment fills within one loan: interest_fee_principal (default), principal_fee_interest, fee_interest_principal
loan_ordering Which open loan a payment reaches first: oldest_first (default), earliest_due_first, newest_first
interest_accrual_start due_date (default: no interest inside the term) or origination. Copied onto each loan when it originates (below)
minimum_payment_cents Minimum per-loan payment on the per-row Pay action; the outstanding amount when smaller
late_terms What a late balance is charged. basis is required: note_rate (the loan's own rate keeps running), penalty_rate (with penalty_rate_bps), flat_fee (with flat_fee_cents), flat_fee_plus_rate (with both), or none (a late status with no late money). Optional: grace_days, day_count (actual_365 default), cap_cents or cap_pct_of_principal_bps on total interest. A cycle-draw program cannot go live until it is set; a per-transaction program without one accrues at the note rate from the due date with no grace (the FRD position). Copied onto each loan when it originates
early_payoff_terms The rebate on an early payoff of a cycle draw: interest_rebate (pro_rata, rule_of_78s, or none/fully_earned) and fee_rebate. A cycle-draw program cannot go live until it is set. Each cycle keeps the rule in force when it closed (its draw), and its payoff quote rebates by that rule however the program changes later; a cycle that closed before any rule was set is quoted by the program's rule now, and the quote holds for the shortest quote_valid_days among the rules it applied. Per-transaction loans have nothing to rebate (each loan keeps a copy for the record)
interest_cap_pct_of_principal_bps Optional ceiling on total interest per loan as a share of its principal. null (the default) is uncapped: interest accrues until the loan is repaid (D-07). 2000 (20%) means a $12,000 loan never accrues more than $2,400. At the ceiling the loan's interest stops growing, statements bill no further interest on it, and the loan stays past_due until it is paid. The lower of this and any late_terms cap applies. Copied onto each loan when it originates or is imported, so one set later never reaches the loans made before it. A live program without one fails the blocking policy.interest_ceiling check in Launch readiness, and a loan import into it warns (no_interest_ceiling)
collections_policy The delinquency ladder the nightly sweep runs (see What runs on DayZero's schedule): {"stages": [{"days_past_due": 30, "action": "suspend_cards", "label": "Cards off"}, …]}. Each stage has days_past_due (a whole number from 1 to 365, no two stages on the same day), action (notify, suspend_cards or suspend_line) and an optional label (up to 40 characters, no two stages with the same name); no other key is accepted. Creating or changing a program refuses a ladder the sweep could not run with 400 CREDIT_720, naming each bad stage. null (the default) is undecided, which fails the blocking policy.collections_ladder check in Launch readiness; {"stages": []} is the decision to suspend nothing for delinquency
card_management dayzero (default): DayZero provisions cards and pushes available credit as the spend limit. partner: you provision cards in the issuer, register them with POST /credit/cards/register, and push limits yourself from credit.line.limit_changed. Once a program has cards, change it with POST /credit/programs/{id}/card-management (see Switching who manages the cards)
allowed_mcc_codes, blocked_mcc_codes The merchant categories a program's cards may and may not buy from, as four-digit MCCs. Set the allow list through its preview and apply calls (see MCC allow list); the program then reports mcc_allowlist_decided_at and mcc_allowlist_source (upload, settlements, preset or manual). An empty list with a date was cleared on purpose; with none it was never decided
event_delivery_mode polling (default): write the outbox only. webhook or both: also POST each event to the endpoints subscribed to it (see Webhooks). GET /credit/events works in every mode
issuing_provider ramp, or mock for an in-memory issuer on staging (Ramp has no sandbox)
deferred_fee_recognition Book the disbursement fee to deferred income at origination and recognise it straight-line over the term
email_statements Email the statement PDF to the line's merchant_email when it issues, in the program's branding (see Notices)
notices The borrower notices DayZero emails to each line's merchant_email, each off until switched on: due_soon, past_due, loan_paid, statement_issued, collections, limit_changed, plus reply_to. See Notices
bank_settlement_matching Settle pending payments, and reverse returned ones, from the bank feed. Off by default. See Bank-matched settlement
compliance Disclosures, adverse-action notices and the template versions they use. Null (the default), or enabled: false, changes nothing. See Compliance
alert_slack_channel, alert_emails Route this program's operator alerts to a named Slack channel and/or email addresses, in addition to the normal channels
interchange_source, interchange_bps Interchange recognition (D-32): estimate earns interchange_bps of every cleared settlement and is trued up when the issuer's report arrives; issuer_report recognises reported rows only; none (default) recognises nothing
underwriting_criteria The scorecard. Validate it first with POST /credit/scorecards/validate; the program response reports policy.criteria_coverage. A PATCH with it (operator only) records the criteria as a new active version at once, with no draft or approval step; see Versions
underwriting_wait_days Days (default 3, at most 30) the nightly sweep leaves a new underwriting assessment pending while the merchant's bank backfill, categorisation and Shopify sync finish. Past it the sweep completes the assessment with whatever data exists. See Underwriting
limit_review_policy Optional. Re-derives each line's limit on a weekly or monthly cadence; see Limit reviews. Null (the default) means limits move only when you change them
limit_change_policy Who may move a line's limit: direct (the default: PATCH applies it), approval_required (every change is a request a second operator approves) or console_only (your token may only request; operators change limits in the console). See Limit changes. Also decides whether a limit review may apply itself and who may decide a proposal; leaving direct while limit_review_policy says apply: direct returns 400 (CREDIT_062)
allowance_policy Loan-loss buckets for the portfolio report: {"buckets":[{"label":"1–30","from_days":1,"to_days":30,"rate_bps":200}, …]}. Defaults: 2% (1–30), 10% (31–60), 25% (61–90), 50% (90+)
borrowing_base_policy Eligibility for the borrowing base: {"include_fees": false, "max_days_past_due": 30, "exclude_on_hold": true, "concentration_limit_bps": 1500, "advance_rate_bps": 8500} (the defaults). A PATCH replaces the whole policy; a field left out takes its default
due_soon_days Days ahead of a loan's due date the credit.loan.due_soon event fires
gl_posting_enabled Post balanced journal entries on the operator's books for every ledger event
legal_lending_entity Printed on every statement PDF
statement_branding Used on the statement and on every email a merchant receives. All keys optional: logo_data_uri (a data:image/... URI — PNG or SVG — embedded in the document; logo_url is read as the same key when it holds a data URI, and an https:// logo_url is used in emails only, because the statement fetches nothing), accent_hex (one colour, used for rules, the amount-due tile and email accents), footer_text (e.g. an address), remittance_text (payment instructions, statement only), support_email (shown on every email; replies go there unless notices.reply_to is set), website (a full https:// address, linked from every email), lender_name (the email sender name; defaults to display_name). Creating or changing a program refuses a malformed value with 400 VALIDATION_490, naming each bad key: accent_hex must be #RRGGBB; logo_data_uri must be a data:image/png;base64, or data:image/svg+xml;base64, URI of valid base64 whose image is at most 256 KB; logo_url must be an https:// URL (or a data URI held to the same rules as logo_data_uri); support_email must be an email address. An empty or absent key is fine. A website that is not http(s):// is still left out rather than rendered. Fonts and layout are not configurable per partner

Rules the program does not state are not guessed. Where a path needs one, it returns 409 CREDIT_POLICY_UNDECIDED, its message naming the missing rule.

Rule changes reach new loans only. A loan is made under the terms in force when it originates, and keeps them until it is repaid. Its rate, fee and term come from its line. The program's pricing rules (interest_accrual_start, late_terms, early_payoff_terms, interest_cap_pct_of_principal_bps, and lending_model) are copied onto the loan when its transaction posts, or when it is imported, as the loan's pricing_terms (see Interest). Its accrual, its cap, its payoff quote and repayment schedule, and every recomputation after a backdated payment, a reversal, a waiver or a dispute use the loan's copy. So a PATCH that changes one of these rules applies to the transactions that post from then on; every loan already on the books is priced exactly as before, for past days and future ones. On a compliance program the change is disclosed to the merchants on those terms. The program's other settings (allocation order, loan ordering, minimum payment, due-soon days) are not pricing and apply to every loan as they change.

In the console, the program form shows the late rule and both early-payoff rebates as choices. A program that carries no rule shows Not decided, and saving sends nothing for it; picking a basis is the decision and is saved, with a grace of 0 too. The amounts a basis needs (the penalty rate, the flat fee) appear only for that basis and must be above zero. A rule set through the API is kept key for key: the form replaces only what the operator changed and keeps keys it does not show, such as a cap or a fee recurrence. A new per-transaction program starts on the FRD position (note rate, grace 0, no rebate), shown selected; a new cycle-draw program starts on Not decided for all three, so its launch checks fail until someone chooses. The launch check's Set the late rule link opens the form at the late rule.

A program update is checked as a whole before any of it is written: the terms, the rule bodies, the limit envelope, the limit-review policy against the envelope and limit_change_policy, and, for a move into active, the launch checks. If any check fails the program is exactly as it was; a patch is never half applied. These refusals are specific to updates:

Code Status Meaning
CREDIT_560 400 The patch sets a required field to null (display_name, status, limit_min_cents, limit_max_cents, default_rate_bps, card_management, limit_change_policy, and the other fields the program cannot be without). The message names them. Leave a field out to keep it; null clears only the optional ones, such as legal_lending_entity or limit_review_policy
CREDIT_561 400 The patch changes card_management while any card on the program is active or suspended. Switching who manages cards moves the cards with it, so it has its own call, POST /credit/programs/{id}/card-management. Restating the current value is not a change, and a program whose cards are all terminated or not yet issued may be patched
CREDIT_564 400 The patch changes lending_model while the program has a credit line, in any status. Create a new program with the other model; the console's program form does not offer the change when editing
CREDIT_660 400 The patch moves a live program into active while a blocking launch check fails; see Going live
CREDIT_661 403 force_activate from an API token
CREDIT_662 400 force_activate without a reason

Open a credit line

Called once, on approval. Returns the id every later call needs. Unspecified terms inherit the program defaults.

bash
curl -X POST https://api.ondayzero.com/api/v1/credit/lines \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "program_id": "PROGRAM_ID",
    "external_merchant_id": "merchant_00417",
    "external_line_id": "line_00417",
    "credit_limit_cents": 25000000,
    "term_days": 30,
    "disbursement_fee_bps": 100,
    "start_date": "2026-03-18",
    "linked_account_name": "Business Checking",
    "linked_account_mask": "3397",
    "plaid_account_id": "acc_31x",
    "autopay_enabled": false
  }'

start_date is the signing date: the effective date named in the credit agreement. PATCH /credit/lines/{id} changes the repayment account and AutoPay, the status (below), and the limit where the program allows it (see Limit changes).

external_line_id is your key for the line, unique within your business. An open that carries one already in use returns that line (201), writes nothing and emits no second credit.line.opened, so an open that timed out is safe to send again, even while the first is still running (the repeat waits for it). The repeat is answered with the line as it stands: terms it carries that differ (another program, limit or term) are not applied, and the response shows the stored ones; change a line with PATCH. Without external_line_id, every call opens another line.

To find a line by your own id, list with it: GET /credit/lines?external_line_id=line_00417 returns the line carrying that id (an array of at most one, empty when there is none), and GET /credit/lines?external_merchant_id=merchant_00417 every line carrying that merchant id. Both match exactly, only within your business, and combine with program_id, status and include_total_count.

merchant_legal_name, merchant_state (a two-letter US state, DC or territory code; anything else is 400 CREDIT_601) and merchant_address (line1, line2, city, state, postal_code, country) say where the merchant is, which decides the disclosure template a compliance program uses. They are optional, except that a program with compliance.enabled refuses a line without all three (400 CREDIT_600, naming the missing fields) and refuses a PATCH that clears one. PATCH /credit/lines/{id} sets them on an existing line.

A card with the line. On a program with card_management: dayzero and issuing_enabled: true, send card to issue the merchant's card in the same call: cardholder_provider_user_id (required, the issuer-side user), and optionally display_name, card_type (virtual, the default, or physical), spend_interval (default TOTAL) and transaction_limit_cents. Once the line is open DayZero issues the card exactly as POST /credit/cards would (see Cards), with the program's MCC allow list and a ceiling of the line's available credit, and the response carries it as card. If the issuer refuses it, the line is still open, card is null and card_issue_error says why (the error code and message, as POST /credit/cards would have returned it); issue the card later with POST /credit/cards. A repeat of the open (same external_line_id) returns the card the first one issued and issues nothing. On any other program card is refused with 400 CREDIT_551 and no line is opened.

When the merchant keeps its books on DayZero, send merchant_business_id (on open, or later with PATCH): the merchant's own DayZero business. An underwriting assessment requested for the line then measures that business rather than yours. It must be a business your token can access; otherwise the call returns 403.

The dashboard

GET /credit/lines/{id} returns everything the summary cards and metadata row need:

Field Meaning
total_balance_cents (also current_balance_cents, the v1 name) Everything owed across every open loan: unpaid principal, unpaid fees, accrued interest. Includes transactions since the last close. Label the dashboard card Total Balance
statement_balance_cents What moved onto the most recent statement at close. Never restated. The statement document labels this Statement Balance and never shows Total Balance, so the two labels do not clash
amount_due_cents What remains of that statement, less the part of it on loans on hold
unbilled_cents Outstanding on loans originated since the last close
past_due_cents Outstanding on past-due loans that are not on hold
on_hold_cents Outstanding on loans on hold for a dispute or a void (see Disputes and voids). Part of Total Balance
available_credit_cents Limit minus Total Balance. Never below zero, and zero while the line is pending, suspended or closed: only an active line can spend, so any other has no headroom and its cards carry none
utilisation_bps Total Balance over limit, in basis points
credit_balance_cents Carried-forward credit from overpayments, refunds and dispute reversals, consumed by the next loan. Where each cent came from and went: Credit balance
next_due_date Earliest upcoming due date among loans not on hold, else the earliest past due
as_of When the figures were computed. Show it
card_management Copied from the program: dayzero or partner
issuing_provider Copied from the program: ramp or mock
status_reason, status_changed_on Why the line last changed status, and the date (UTC). Null until it does
suspended_via Who holds a suspended line suspended: collections or operator (see Line status). Null when it is not suspended
merchant_legal_name, merchant_state, merchant_address Where the merchant is (above)

Line status

PATCH /credit/lines/{id} with status (pending, active, suspended, closed) moves a line between states. A change needs a reason (up to 255 characters); without one the call returns 400 with code CREDIT_030. Sending the status the line already has changes nothing and needs no reason, except suspended for a line collections holds suspended (below). The reason is stored on the line (status_reason, status_changed_on) and carried on the event.

Move Event
any state to suspended credit.line.suspended
suspended to active credit.line.reinstated
any state to closed credit.line.closed

Each carries previous_status, status, reason, actor, changed_on, and the figures outstanding at that moment: outstanding_cents, past_due_cents, open_loan_count, credit_balance_cents and has_outstanding_balance. actor is operator (a signed-in person in the console), api_token (your integration), or collections (the nightly ladder, below). Closing a line that still owes money is allowed; the event says so, and the balance stays collectable. Other moves, such as pending to active, record the reason and emit nothing.

Who holds a suspension. A suspended line says who suspended it, as suspended_via on the line: collections when the collections ladder suspended it, operator when anyone else did (a person in the console or your integration through PATCH). Any move out of suspended clears it. The rule it carries: collections reinstates only a line it suspended itself, when the merchant catches up; a line an operator suspended is reinstated only by an operator, even if it was suspended while the line was delinquent and the merchant then catches up. To keep a line that collections suspended from being reinstated whatever the merchant pays, send status: "suspended" with a reason for it: the suspension becomes an operator's (suspended_via: "operator", the reason stored as status_reason), with no event, since the status does not change. Without a reason that call is 400 CREDIT_030. A line suspended before lines recorded this was stamped collections by a one-off backfill wherever the record proves it (the line is in a suspend_line stage it entered on or before its suspension, or its newest credit.line.suspended event says actor: collections and is dated that day); any other has suspended_via: null and is never reinstated automatically.

A suspension or closure may carry reason_code: credit_risk, delinquency, fraud, merchant_request or administrative. It changes nothing on the line or the event. On a program with adverse-action notices on, the first three record an adverse action.

Only an active line can spend. A pending, suspended or closed line has no available credit: the issuer push gives its cards a ceiling of what they have already spent, a card minted meanwhile gets a limit of zero, and a partner-managed line's credit.line.limit_changed carries zero. The push happens with the status change itself, so a suspension stops the cards at once, and the headroom comes back with the move to active (an opening, a reinstatement): every activation, by whatever path, pushes the line's available credit at once, once (the issuer push for DayZero-managed cards, credit.line.limit_changed for partner-managed ones), rather than waiting for the nightly push. A limit change in the same call pushes instead, so the issuer still hears once.

What suspension and closure stop, and what they do not. Suspending a line stops new spending, and nothing else: the debt keeps its terms. A suspended line's loans keep accruing interest day by day, go past due on their own due dates, are billed at month-end with a statement like any other, get their credit.autopay.due events, take payments, and stay on the collections ladder, which moves the line to deeper stages and, if collections suspended it, reinstates it when the merchant catches up. The merchant can still do everything on the borrower portal that helps them pay, AutoPay included. A transaction that clears late on a suspended card is real: it originates its loan, and a refund or a dispute on the line is applied as on any other. A suspended line is not given a limit review (it spends nothing until it is reinstated, and is reviewed in its next cadence window after that). Closing a line ends it for good, and the same holds while it still owes: it accrues, closes its periods and issues statements, takes payments and is collected until it is paid off, after which the period that shows the payoff is closed and the line is left alone. Collections never moves a closed line to suspended or back to active, and never releases a closed line's cards. A closed line that never owed anything is never touched.

On a compliance program a line can open pending for the merchant's acknowledgement of its disclosure. While that disclosure waits, a move to active is refused (400, CREDIT_617): the line opens when the merchant acknowledges. An operator in the console may activate it without the acknowledgement by sending acknowledgement_override: { "reason": "..." } with the status change; the disclosure is then recorded as overridden, not acknowledged (see Acknowledgement). An API token can never send it (403, CREDIT_618). The override is ignored on any other move, or when nothing awaits.

Closing is line administration. Where the program's limit_change_policy is console_only, an API token that tries to close a line receives 403 with code CREDIT_031, and a signed-in operator closes it in the console instead. limit_change_policy is the program's line administration policy, and it governs the limit too (next section). Suspending and reinstating stay open to your token whatever the policy, so a risk hold never waits on a person.

Limit changes

A line's limit is a credit decision, so the program's limit_change_policy says who may move it and whether a second person must agree. It is set on the program (POST /credit/programs, PATCH /credit/programs/{id}) and applies to every line under it.

limit_change_policy PATCH /credit/lines/{id} with a new credit_limit_cents A limit-change request
direct (the default) Applied at once, for your token or an operator Allowed, for a partner that wants the approval trail; PATCH is the direct path
approval_required 409 with code CONFLICT_093 for every caller, operators included; the body carries credit_line_id The only way to change the limit: a console operator other than the person who asked approves it
console_only 403 with code CREDIT_031 for an API token; a signed-in operator changes it in the console How your token asks; an operator approves it, or changes the limit directly

A PATCH that sends the limit the line already has is not a change and is never refused, and the policy governs only the limit: the other fields on the same PATCH behave as described above. A line opened without a program has no policy, and its limit changes with PATCH.

Ask for a change:

plaintext
POST /credit/lines/{credit_line_id}/limit-change-requests
{ "requested_limit_cents": 30000000, "reason": "Q4 ad budget doubled" }

reason is required (up to 255 characters). The call returns 201 with the request. It is refused with 400 when the limit asked for is the one the line has (VALIDATION_480) or sits outside the program envelope (CREDIT_512), with 400 (CREDIT_068) when the line has no program, and with 409 (CONFLICT_094, the body carrying the pending limit_change_request_id) when a request is already pending on the line: one at a time.

Field Meaning
status pending, approved, rejected, cancelled or expired
previous_limit_cents, requested_limit_cents The limit when asked, and the limit asked for
reason Why, as sent
requested_via api_token or console
requested_by_user_id The person who asked; null when a token asked
requested_by_token_jti The id of the API token that asked, when one did
decided_by_user_id, decided_at, decision_note Who approved, rejected or cancelled it, when, and their note
applied_at When the approved limit was written to the line
expires_at When it lapses if nobody decides it: 14 days after it was made (UTC)
credit_line_id, credit_line_external_id, program_id The line, your external_line_id for it, and its program

GET /credit/lines/{id}/limit-change-requests lists a line's requests newest first (status filters; paged, see Pagination), and GET /credit/limit-change-requests/{id} returns one.

Deciding is an operator action in the console, behind the firm's MFA step-up; an API token gets 403:

plaintext
POST /credit/limit-change-requests/{id}/decision
{ "decision": "approve", "note": "Credit committee, 1 Oct", "limit_cents": 28000000 }

decision is approve or reject; note is optional and stored as decision_note. Approving moves the line through the same path as a direct PATCH: the limit is checked against the envelope, one credit.line.limit_changed event is emitted (its payload carries limit_change_request_id, with the request's reason), and the new available credit is pushed to the issuer once. The request reads approved, with applied_at set. limit_cents approves a different figure inside the envelope: requested_limit_cents keeps what was asked, and decision_note begins "Applied $280,000.00 instead of the $300,000.00 requested." Rejecting leaves the limit alone and emits nothing.

  • The person who asked cannot approve (400, VALIDATION_479), under every policy; they may reject or cancel. A request your token made has no requester person, so any operator may approve it, whoever minted the token.
  • Approval sets the line to the figure approved, not a step from where it was. Approving the limit the line already has (it may have moved since the request was made) returns 400 (VALIDATION_480).
  • A request that is not pending is final: deciding or cancelling it returns 400 (VALIDATION_478), the message naming its status.
  • An unknown request id returns 404 (NOT_FOUND_110).

DELETE /credit/limit-change-requests/{id} withdraws a pending request; it becomes cancelled. Your token may cancel only a request it made (403, CREDIT_069, otherwise); an operator may cancel any.

A pending request lapses at expires_at. The nightly sweep marks it expired (in its limit-review step, counted under limit_reviews.expired), and so does an attempt to approve, reject or cancel it after that instant, which is refused with 400 (VALIDATION_478). A lapsed request never blocks a new one: asking again expires it first. On a sandbox the sweep's clock counts: running it 15 days ahead expires a request made today.

The request itself emits no event; the limit only ever moves with a credit.line.limit_changed event. Poll the request, or watch for that event, to learn that it was approved.

In the console, Change limit on a line whose program is approval_required sends a request (the button reads Send for approval); under direct and console_only it changes the limit. The line's Limit reviews tab lists its limit-change requests above the reviews, each with Approve (optionally at another figure) and Reject, both taking a note; Approve is disabled for the person who asked. The program form sets the policy under Limit changes.

Code Status When
CONFLICT_093 409 A PATCH changed the limit under approval_required
CONFLICT_094 409 A request is already pending on the line
CREDIT_031 403 An API token changed the limit (or closed the line) under console_only
CREDIT_068 400 The line has no program, so there is no policy to request under; use PATCH
CREDIT_069 403 An API token cancelled a request it did not make
CREDIT_512 400 The limit asked for or approved is outside the program envelope
VALIDATION_478 400 The request is not pending, or has just expired
VALIDATION_479 400 The person who asked tried to approve
VALIDATION_480 400 The limit asked for or approved is the line's current limit
NOT_FOUND_110 404 No such request

A card transaction posts

Push settled transactions, never authorisations. On a DayZero-managed Ramp program the cleared webhook does this for you: a transaction in Ramp's CLEARED ("cleared and settled") or COMPLETION ("completed processing") state originates the loan, the same id under both states originates it once, and PENDING / PENDING_INITIATION are ignored until they clear. A cleared or declined transaction on a credit-line card reaches the credit ledger from the webhook whether or not the Ramp connection's accounting sync is switched on, and it is never stored among the operator business's own Ramp transactions or booked as its expense, by the webhook or by the scheduled accounting sync: it is the merchant's draw, and the loan is its record. The webhook fetches the transaction with a client that renews an expired Ramp token first, so a lapsed token does not leave a delivery without its card. Idempotent on external_settlement_id; a re-pushed batch reports duplicates rather than double-counting. Up to 1,000 per batch. A negative amount is a refund.

bash
curl -X POST https://api.ondayzero.com/api/v1/credit/card-settlements \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "credit_line_id": "LINE_ID",
    "settlements": [{
      "external_settlement_id": "rmp_exp_9f3k2a",
      "settled_on": "2026-05-20",
      "amount_cents": 740000,
      "merchant_name": "Google Ads",
      "card_last_four": "9721",
      "merchant_category_code": "7311"
    }]
  }'

Each accepted result carries the loan it originated:

json
{
  "external_settlement_id": "rmp_exp_9f3k2a",
  "settlement_id": "...",
  "loan_id": "...",
  "principal_cents": 740000,
  "disbursement_fee_cents": 7400,
  "term_days": 30,
  "due_date": "2026-06-19",
  "status": "accepted"
}

A refund result says which branch applied: refund_disposition is reduced_loan when it reduced the loan it relates to (send refund_of_external_settlement_id to name it), or carried_forward when the loan was already repaid or the refund exceeded what was outstanding. Carried-forward credit is applied against the merchant's next loans and never paid out. A refund dated before the loan it reduces last accrued interest re-accrues that loan from the day after the refund's date on the principal it leaves, unless something else has reached the loan since that date (see the backdated payments rule under Payments).

Transactions belong to the statement period containing the date they posted at the issuer.

Disputes and voids

A posted transaction can stop being plainly owed: the merchant disputes it with the ad platform through the issuer, or the issuer reports that a transaction it cleared was voided. Either way its loan goes on hold until the question is settled.

bash
curl -X POST https://api.ondayzero.com/api/v1/credit/loans/LOAN_ID/dispute \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Invalid clicks on campaign 4471", "provider_dispute_id": "rmp_dsp_81c2", "amount_cents": 300000}'
Field Meaning
reason Required. Why the charge is disputed, in words
amount_cents Principal in dispute. Omit for the whole transaction (its principal less anything an earlier dispute already reversed)
provider_dispute_id The issuer's id for the dispute. When sent, it is the idempotency key
opened_on First day of the hold. Defaults to today (UTC). It may be in the past, but not before the transaction date, not before an imported loan's import date (CREDIT_652), and not on or before the date of a payment already applied to the loan

The call returns 201 with the dispute: id, kind (dispute, or void for an issuer void), status (open or resolved), amount_cents, opened_on, source (partner for an API token, operator for the console, webhook and reconciliation for the issuer paths below), and the loan's loan_status, loan_hold_status and loan_outstanding_cents after the call. Opening it again returns the same dispute: on provider_dispute_id when one is sent, otherwise on the loan's open dispute (a retry that adds the issuer's id records it on that dispute). A loan has one open dispute at a time. Disputes can be opened on a repaid loan (a chargeback after repayment) but not on one that has been written off.

While a loan is on hold:

  • Interest. It accrues nothing from opened_on, and the hold days are never charged afterwards — not when the hold is lifted, and not when a payment backdated before the hold re-accrues the loan. Days already accrued from opened_on are taken back with an adjustment register entry. Interest accrued before the hold stays, and an imported loan's interest to its import date is never taken back.
  • Collections. It is left out of days past due and of the collections ladder. A loan whose due date passes during the hold is not flagged past_due and fires no credit.loan.past_due or credit.loan.due_soon while held.
  • AutoPay. No credit.autopay.due is emitted for it.
  • Payments. An unallocated payment's waterfall passes it by, and the line's payoff quote leaves it out; a payment with loan_id naming it, or payoff-quote?loan_ids= naming it, still reaches it (with nothing projected for hold days).
  • Dashboard and statements. Its balance stays in Total Balance, and so against the limit and in available credit. It is reported as on_hold_cents and not in past_due_cents or next_due_date. The Statement Balance is never restated; amount_due_cents on the line and the summary band is what remains of the statement less the part of it on held loans. Statement periods and documents carry on_hold_cents (the part of outstanding_cents on held loans). Each loan row carries on_hold and hold_status: as the statement was issued on the issued rows, today's on view=current. A hold placed after a statement was issued does not change it; it is listed under Since this statement, where the printed statement also states the amount on hold.
  • Loans. Each loan carries on_hold, hold_status (none, disputed, voided), hold_reason, hold_started_on, hold_resolved_on, hold_outcome, and principal_reversed_cents, fee_reversed_cents, interest_reversed_cents. A held loan cannot be written off (400 CREDIT_207): resolve the dispute first.

Resolve with POST /credit/disputes/{id}/resolution and { "outcome": "...", "resolved_on": "YYYY-MM-DD", "notes": "..." }. The hold covers the days from opened_on up to the day before resolved_on; resolved_on may be in the past.

outcome Effect
merchant_won The disputed principal is reversed off the loan, with the disbursement fee and the interest accrued before the hold in the proportion the disputed principal bears to the transaction (half the principal, half the fee and half the interest; the whole transaction reverses all three). A refund of the same transaction that arrived after the dispute opened already reduced the loan and is not reversed again. Whatever remains accrues again from resolved_on on the reduced principal
lender_won The charge stands. Accrual resumes from resolved_on; nothing is charged for hold days. The due date never moved, so a loan whose due date passed during the hold is past due from resolved_on and counts its days past due from that due date
voided The whole loan is reversed as if the transaction never cleared: principal, fee and all interest
released The hold is lifted without a decision (the dispute was withdrawn, or a void report did not stand). Same effect as lender_won

A reversal posts one adjustment register entry on resolved_on and, with GL posting on, one journal entry (the issuer payable, fee income — or deferred fee income for the part not yet recognised — and interest income are reversed against the receivables). Money the merchant had already paid towards an amount that is reversed is carried forward as credit balance, exactly as an overpayment (a dispute entry in the credit balance): a voided loan with payments on it turns those payments, and any carried-forward credit it consumed, into credit balance. The dispute records principal_reversed_cents, fee_reversed_cents, interest_reversed_cents and credited_cents. A loan reversed in full reads paid with hold_outcome saying why. Resolving again with the same outcome returns the dispute as recorded; a different outcome is refused.

GET /credit/lines/{id}/disputes lists a line's disputes, newest opened first (status=open or resolved to filter), with items, next_cursor, has_next, limit. Events: credit.loan.disputed when a hold is placed, credit.loan.dispute_resolved with the outcome, the hold days and the amounts reversed and credited.

From the issuer. For DayZero-managed Ramp cards, holds also arrive without a call:

  • A transaction DayZero recorded as cleared that Ramp later reports DECLINED (the transactions.declined webhook, or the state on nightly reconciliation; VOIDED, CANCELLED and REVERSED from other sources are read the same way) goes on a voided hold. With nothing paid on the loan it is resolved voided at once. With payments on it the hold waits for an operator to resolve it (voided, or released if the void does not stand).
  • Ramp has no disputes resource and no dispute webhook. A transaction carries a disputes list (CREATED_MERCHANT_ERROR, CREATED_UNRECOGNIZED_CHARGE, CANCELLED_BY_CUSTOMER, CANCELLED_BY_RAMP, RESOLVED_BY_RAMP), which DayZero reads whenever it sees the transaction — on the cleared webhook, on reconciliation over the last 7 days, and on the nightly dispute look-back over the last 30 (see What runs on DayZero's schedule). A CREATED_* entry opens a disputed hold keyed on the entry's id (or takes over a dispute you already opened on that loan without an id); a cancellation releases it. RESOLVED_BY_RAMP is noted on the dispute (provider_status) and nothing more, because Ramp does not say who won: record the outcome yourself. A dispute Ramp first lists on a transaction older than the look-back (30 days by default) is not picked up by itself; open it through the API.
  • An issuer's date is never refused, only moved: a hold starts no earlier than the transaction date, no earlier than an imported loan's import date, and after the last payment applied to the loan. A dispute Ramp dates before an imported loan joined DayZero's books (one opened on the old system) holds from the import date, and the date Ramp gave is kept on the dispute as reported_opened_on; a void reported for such a loan holds from the import date the same way. The imported interest is untouched.
Code Status Meaning
CREDIT_201 400 The loan cannot be disputed: it was written off, or earlier disputes already reversed all of its principal
CREDIT_202 400 The loan already has an open dispute under a different provider_dispute_id
CREDIT_203 400 amount_cents is more than remains to dispute on the transaction
CREDIT_204 400 The dispute was already resolved with a different outcome
CREDIT_205 400 opened_on is before the transaction or on or before a payment applied to the loan, or resolved_on is before opened_on
CREDIT_206 400 provider_dispute_id is already recorded against another loan
CREDIT_207 400 The loan is on hold; resolve the dispute first (write-off)
CREDIT_208 404 No such dispute
CREDIT_652 400 opened_on is before the loan was imported; the message gives the earliest date allowed

Loans

GET /credit/lines/{id}/loans returns one row per card transaction, oldest first, with server-side filters and a cursor:

Parameter Type Description
status string Comma-separated: active, past_due, paid, written_off
start, end date Inclusive range
date_basis string transaction (default) or due
cycle_id string Loans originated in one statement period
limit, cursor Paging

Each loan carries transaction_date, merchant_name, principal_cents, disbursement_fee_cents, term_days, due_date, interest_accrued_cents, the paid buckets, outstanding_cents, and status, plus on_hold and the hold fields described under Disputes and voids, and pricing_terms, the program rules it was originated under (see Interest). The status is authoritative.

Status Meaning
active Within its term, not yet due. No interest accruing
past_due Past its due date with an amount outstanding. Accruing daily
paid Principal, fee, and interest all zero
written_off Charged off under the collections policy

GET /credit/lines/{id}/subledger returns the Transactions tab in one call: the summary band, the loan rows (same filters), and the period history. GET /credit/lines/{id}/subledger/export returns the same rows as CSV, with the same filters except cycle_id: one row per loan with transaction_date, merchant, loan_id, settlement_id (the issuer's transaction id), amount, disbursement_fee, payback_term_days, due_date, accrued_interest, principal_paid, fee_paid, interest_paid, outstanding and status, then on_hold (yes or no), hold_status, paid_on and past_due_since. The last four were added after the others, which keep their order and names, so a reader that takes columns by position is unaffected.

Repayment schedule

GET /credit/lines/{id}/schedule answers "what do I owe, and when": every open loan, earliest due first, with what closes it today and what leaving it unpaid will cost.

Parameter Type Description
as_of date The schedule date. Defaults to today (UTC)
horizon_days integer How far ahead projections run, 1 to 366. Default 60

Each row carries loan_id, external_settlement_id, merchant_name, transaction_date, due_date, days_to_due (negative once the due date has passed), status, on_hold, the three buckets principal_outstanding_cents, fee_outstanding_cents and interest_accrued_cents (interest owed on as_of, including days the nightly accrual has not posted yet), their sum payoff_today_cents, and projected: the payoff on the due date and 30 and 60 days after it, each as {on, interest_cents, total_cents}. A loan already past due projects from as_of instead of its due date. Points beyond the horizon are pulled back to its last day, so a narrow horizon returns fewer points.

totals sums the rows: due_within_7_days_cents and due_within_30_days_cents (each loan due in the window, priced on its own due date), past_due_cents (loans whose due date is before as_of, priced on as_of), and payoff_today_cents. next_statement_on is the day the next statement issues: the day after the current period ends, or as_of itself when an ended period has not closed yet.

Every figure is priced by the same per-loan computation as the payoff quote, so a row's payoff_today_cents is that loan's line on GET /credit/lines/{id}/payoff-quote?as_of=…, totals.payoff_today_cents is the quote's total_cents, and each projected point is what payoff-quote?as_of=<on>&loan_ids=<loan_id> returns. The schedule is per loan, so it exists only on per_transaction programs; a cycle_draw line returns 400 (CREDIT_072).

Build the summary band from its summary object alone: total_balance_cents, statement_balance_cents, amount_due_cents, statement_period_start, statement_period_end, statement_closed_on, statement_due_date, statement_status (open or paid), and statement_download_url, plus unbilled_cents, past_due_cents, on_hold_cents, credit_balance_cents, available_credit_cents, utilisation_bps, and next_due_date. Its available_credit_cents is the dashboard's figure: zero while the line is pending, suspended or closed, and always equal to available_credit_cents on GET /credit/lines/{id}.

Interest

plaintext
daily interest     = remaining principal × (rate ÷ 10,000) ÷ 365
remaining principal = original principal − principal repaid − credits applied

Each day is stored as its own record at full precision; the loan's interest is the sum of those records rounded once. Interest is charged on principal only: fees do not accrue interest and unpaid interest does not compound. A loan repaid on or before its due date carries no interest at all. There is no grace period unless the program sets one, and accrual runs until the loan is repaid in full or the program's cap is reached.

The rules in that sentence are the ones the loan was originated under. Each loan carries them as pricing_terms:

Field Meaning
interest_accrual_start due_date or origination, as the program had it
late_terms The program's late rule as it stood; its grace_days, day_count and caps shape the loan's daily accrual. Null means the FRD defaults (no grace, actual/365, no cap)
early_payoff_terms The program's early-payoff rule as it stood, kept for the record
interest_cap_pct_of_principal_bps The program's ceiling on the loan's total interest. The lower of it and any cap in late_terms applies
lending_model The program's model when the loan was written
terms_version, source, captured_at The record's layout (1), how the loan got its terms (origination, import, or backfill for a loan that existed before loans carried their terms, which was given its program's rules as they stood on that day), and when (UTC)

A program change never re-prices a loan already written: the daily accrual, the cap, the payoff quote and the repayment schedule read the loan's pricing_terms, and so does a backdated payment that recomputes days already accrued. The rate itself is the loan's rate_bps, copied from its line.

Adjustments

An operator can reduce what a merchant owes on one loan without a payment: waive part of the fee, waive part of the accrued interest, or credit part of the principal as goodwill. This is an operator action (Operator actions): tell us and we record it in the console.

plaintext
POST /credit/loans/{loan_id}/adjustments
{ "type": "waive_fee", "amount_cents": 1200, "reason": "Fee charged twice by the processor", "effective_on": "2026-04-20" }
type Reduces Afterwards
waive_fee The unpaid disbursement fee —
waive_interest The unpaid accrued interest Interest keeps accruing on the remaining principal
goodwill_credit The unpaid principal Less principal accrues less interest from effective_on

amount_cents may not exceed what is outstanding in that bucket; more returns 400 (CREDIT_401). reason is required and becomes the ledger memo. effective_on defaults to today and may not be before the loan was originated (CREDIT_403), before an imported loan's import date (CREDIT_652), or in the future. A goodwill_credit may not be dated before a payment already applied to the loan (CREDIT_403); one dated before days already accrued removes those days and accrues them again on the reduced principal, as a backdated payment does. A loan that is paid or written off has nothing to adjust (CREDIT_402).

Each adjustment posts one adjustment entry to the ledger with the bucket it reduced and, when the program posts to the GL, debits an expense account (Fee Waivers, Interest Waivers or Goodwill Credits, created on first use) against Notes Receivable or Interest Receivable. A loan brought to zero is paid on effective_on and emits credit.loan.paid; no other event is emitted, because the adjustment is on the ledger and the statement. The response carries the adjustment and the loan as it stands after it; the loan's fee_waived_cents, interest_waived_cents and goodwill_credit_cents are the running totals. Adjustments are never payments: the paid buckets do not move, and statements report them in their own column. GET /credit/loans/{loan_id}/adjustments lists a loan's adjustments, newest first. A payment recorded later but dated before an adjustment lifts the adjustment and applies it again after itself (see Payments); one the earlier payment leaves nothing to reduce refuses a payment you record (CREDIT_650). A pending debit that settles at an earlier date is booked anyway, and the adjustment takes effect for what that money left in its bucket: amount_cents is then what took effect (possibly zero) and requested_amount_cents what was asked for (null when it took effect in full), and the register entry's memo says why.

Statements

The month closes on its last calendar day. Close applies the month's accrued interest, freezes the Statement Balance (everything outstanding across open loans at the period end), and issues the statement. The interest a statement applies to a loan is the movement across the period in the loan's own interest, as the loan carries it: its daily records rounded once, half a cent up, and held at its cap. So a loan's statements add up to exactly its interest, and once its cap is reached its statements bill it no more interest. Close also stores the statement exactly as issued, so an issued statement never changes: what happens to its loans afterwards is shown beside it, under Since this statement, never by restating it.

The position at the period end. Payments and adjustments count when they are dated by the period end, and so do write-offs: a loan written off after the period end — even one written off before close ran — is billed as it stood at the period end (principal, fee and interest, with the status it had then), and the write-off is listed under Since this statement. A loan written off by the period end is left off.

Loans brought over from another system. An imported loan is on statements from its import date. No statement is issued for a period that ended before it. The statement whose period holds the import date brings it forward: what it owed as imported (principal, fee and interest to date, less what was repaid or credited before the import) is part of that statement's previous_balance_cents, so it is never a new purchase or fee, its interest from before the import is not interest of the period, and what it was repaid before the import counts in its paid-to-period-end columns but never in the period's payments. The roll-forward closes as for any statement: previous balance plus new purchases, fees and interest, less payments, is the statement balance. A payment to the line dated before a loan's import date passes that loan by: the loan was not on DayZero's books that day, its interest to the import date is the figure it was imported with, and the money is applied to the loans that were there (or carried as credit).

Due date. At close, the statement's due date is the earliest due date, among the loans on that statement, that falls on or after the day close runs — or the close date itself when every loan on it is already due. It is stored at close and never recomputed from the issued date. In the worked example below, the March statement (closed 1 April) is due 4 April, the first loan's due date; the April statement (closed 1 May) is due 1 May, because every remaining loan is already past due — a statement can be due the day it issues.

GET /credit/lines/{id}/statements lists periods with amount_due_cents (the statement balance), paid_cents (how much of that balance has been retired since), outstanding_cents (what remains of it), on_hold_cents (the part of outstanding_cents on loans on hold), period_status (open or paid), reconstructed (see Statements closed before snapshots), and download links. GET /credit/lines/{id}/statements/{cycle_id} returns the document:

format Returns
json (default) The statement payload with one row per loan and its period activity, issued, terms and since_statement
pdf The rendered document with the program's branding and lending entity, then "Since this statement"
csv The transaction-level spreadsheet for reconciliation
html The printable page
view Loan rows Default for
issued Exactly as the statement was issued json, pdf, html
current Recomputed from today's records, by the close's rules csv

view=current follows the close: a loan written off by the period end is billed nothing and its interest is not applied (it stays on the rows of the period it was written off in, at zero), and interest is the loan's own, rounded once and held at its cap, so each row's interest is what the close applied, to the cent. pdf and html are always the issued document; view=current with either is 400 (CREDIT_641), and a view that is neither is 400 (CREDIT_640).

The CSV has one row per transaction: statement_period_start and statement_period_end, transaction_date, merchant, loan_id, settlement_id (the issuer's transaction id, which is the loan's external_settlement_id, not DayZero's settlement id), amount (principal), disbursement_fee, payback_term_days, due_date, interest_applied_this_statement, interest_accrued_to_period_end, what was paid inside the period (principal_paid_this_period, fee_paid_this_period, interest_paid_this_period), adjustments_this_period, the cumulative principal_paid_to_period_end, fee_paid_to_period_end, interest_paid_to_period_end, then outstanding_at_period_end, status, originated_this_period, and hold_status. The period columns count payments dated from the period start to the period end, so for each loan last month's cumulative figure plus this month's period figure equals this month's cumulative figure, and the three period columns summed over every row equal the statement's payments total. A loan paid off during the period stays on that statement for this reason. By default the spreadsheet is always current: a payment backdated into a closed period, or reversed out of one, moves these columns even though the issued statement balance is never restated. view=issued returns the same columns as the statement issued them. The CSV does not break out individual payments; per-payment allocation by loan is on GET /credit/lines/{id}/payments. The JSON payload carries the same figures per loan as principal_paid_this_period_cents, fee_paid_this_period_cents and interest_paid_this_period_cents.

Operator adjustments are not payments and are never in the paid columns. adjustments_this_period (adjustments_this_period_cents in the JSON) is the fee waived, interest waived and goodwill credited by adjustments effective inside the period; outstanding_at_period_end and the statement balance are net of every adjustment effective by the period end. A loan adjusted to zero during the period stays on that statement.

What is frozen

At close the statement is stored as issued (snapshot_version 1): every loan row with each figure the statement shows (principal, fee, interest applied in the period and accrued to the period end, what was paid in the period and to its end, adjustments in the period, refund credit, principal written off, what a dispute resolution reversed, unpaid principal, fee and interest, the outstanding at close, due date, status and hold, merchant and description), the roll-forward, the period dates and due date, the statement balance, the lender's identity and branding at that moment (legal_lending_entity, display_name, statement_branding), the terms (terms: rate, term, disbursement fee, when interest starts, grace, balance mode) and the totals. The rows are checked against the totals frozen on the period when they are written. A cycle-draw statement stores its settlements and its one draw the same way.

The issued part of every document of a closed period — the JSON's period, balances, roll-forward, loan rows, lender identity and branding, the PDF and the HTML down to the footer — comes from that record, so it reads the same however often, and however late, it is rendered: a refund, write-off, adjustment, dispute, payment reversal or backdated payment after the period changes none of it, and a later change to the program's branding does not reach an issued statement. issued in the JSON says where it came from: snapshot_version, reconstructed, ties (whether the rows sum to the frozen totals; one that does not is logged for us and the statement is kept as stored) and captured_at. status, period_status, paid_cents, outstanding_cents and on_hold_cents on the document are today's figures, and the rest of today's position is in since_statement.

Since this statement

Below the statement, the PDF and HTML carry a separate section, and the JSON a since_statement object, listing what has touched the statement's loans since it was issued. It starts from the balance as issued (issued_balance_cents: the loan rows' outstanding, or a cycle-draw statement's amount due) and ends in outstanding_now_cents, what those loans owe today. Each of items has date, kind, description, amount_cents (negative reduces what is owed, positive adds to it), loan_id and external_settlement_id when it touched one loan, and reference. The section is left out of the PDF while nothing has happened.

kind What it is
payment A payment dated after the period, or one backdated into it and recorded after the statement was issued (it carries its own date). A payment re-applied after an earlier-dated payment shows the change
payment_reversal A payment returned. One received and returned after the statement shows as both lines, which net to nothing
refund A refund credited against one of the statement's loans
credit Carried-forward credit applied to a loan, or withdrawn because the payment that created it was returned (positive), or changed when a backdated payment had the loan take credit again at its origination
adjustment A fee or interest waiver, or a goodwill credit
write_off A write-off, full or partial, dated after the period end (including one recorded before close ran), or a partial write-off reduced because a debit that landed earlier left less principal (positive)
dispute_hold A loan put on hold for a dispute or a void. No amount: nothing is due on it until the hold is resolved
dispute_resolution How the hold ended: what a merchant win or a void took off the loan, or zero when the charge stands or the hold was released (a resolution a backdated payment re-applied shows what it takes off now). A resolution the statement already counted appears only when a backdated payment re-applied it afterwards, with the change. Also, when credit the loan consumed is withdrawn, or a payment that paid it is returned, after the merchant won, what the reversal takes off in its place, on the day of the withdrawal or the return: it nets the credit or payment_reversal line beside it to what the merchant still owes
late_charge A late charge on a cycle-draw statement
interest Interest charged on the statement's loans after the period end, as one line, net of any recalculation
recalculation A change no single record explains, so the section still sums to Outstanding now. It is rare and we are alerted when one appears

statement_paid_cents and statement_remaining_cents say how much of the balance as issued has been retired since and how much remains; they are the document's paid_cents and outstanding_cents, and what remains never exceeds the statement balance, even on a rebuilt statement whose rows add up to more than the balance frozen at close.

Money after the period

A payment after the period on a loan that has since accrued more interest retires that interest first, then the fee, then principal: the waterfall is the same for every payment. For the question "has this statement been paid?", money received after the period is attributed to the statement's balance first, loan by loan, capped at what each loan owed on the statement, and only the rest to interest charged later. So paying off one loan, later interest included, retires exactly that loan's share of the statement and never another loan's; a part payment counts in full against the loan's share. A refund, credit, adjustment, write-off or dispute resolution after the period retires a loan's share the same way. A payment the statement counted that is returned afterwards, and credit withdrawn with it, re-open debt outside the issued balance: they appear under Since this statement and on the next statement, and the statement's remaining figure never exceeds its balance.

That one figure is what the statement list's paid_cents, outstanding_cents, on_hold_cents and period_status, the document's, the summary band's amount_due_cents (and the line's), the period history, the period's own status (it reads paid once nothing of its balance remains) and the borrower portal's "Still owed" all read. Days past due, the collections ladder and AutoPay are per loan, against each loan's own due date, and do not look at statements. In the worked example, $95.62 of the $30,000 payment on 15 April retired interest charged in April, so it counts against the April statement and the March statement shows $29,904.38 paid and $15,545.62 remaining.

Statements closed before snapshots

A period that closed before statements were stored as issued has no record yet. The first time it is rendered, its record is rebuilt from the books as at its period end — refund credit, write-offs and dispute reversals dated after the period end are taken back out — stored, and flagged reconstructed: true; every later render reads that stored record. The statement list carries reconstructed, the PDF and the portal say the statement was rebuilt, and ties says whether the rebuilt rows match the totals frozen at close. A copy of a business (a sandbox cloned from another) does not inherit its source's stored statements: each of its closed periods is rebuilt the same way from the copy's own loans and payments, so nothing paid to the source after the copy was made shows on it. We rebuild the whole book in one pass with scripts/admin/credit/backfill_statement_snapshots.py (--business-id for one lender, --apply to write; a dry run otherwise).

Code Status Meaning
CREDIT_640 400 view is not issued or current
CREDIT_641 400 view=current with format=pdf or html: the printable statement is always the issued one

Payments

DayZero never debits an account. Request a payoff figure, execute the debit through your processor, then report it. The response carries the allocation and corrected balances, so the UI updates without a second round trip.

bash
# 1. Quote. Never use a displayed balance as the payoff figure.
curl "https://api.ondayzero.com/api/v1/credit/lines/LINE_ID/payoff-quote?as_of=2026-05-20" \
  -H "Authorization: Bearer dz_your_token_here" -H "x-business-id: YOUR_BUSINESS_ID"

# 2. Execute the debit on your side.

# 3. Report it. paid_on is the date the money landed.
curl -X POST https://api.ondayzero.com/api/v1/credit/payments \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "credit_line_id": "LINE_ID",
    "external_payment_id": "pay_0091",
    "amount_cents": 3000000,
    "paid_on": "2026-04-15",
    "method": "ach_payliance"
  }'

The quote's date. GET /credit/lines/{id}/payoff-quote takes as_of (default today, UTC) and, for one or more loans, loan_ids. A quote projects forward from the loans as they stand, so on a per-transaction line it cannot price a day the book has already been processed past: an as_of earlier than both today and the line's processed-to date (the latest of its register entries, its loans' last accrual, and any hold opened or resolved) is 400 CREDIT_730, and the message names the earliest date it will quote. Those loans already carry what happened after that day (interest accrued since, a later payment, a loan originated since), so a figure for it would be today's under an old date. Today and any later date are always quoted, and so is the processed-to date itself: in the worked example, a quote for 15 April asked for once that day's accrual has run, before the payment is recorded, reads $45,545.62. Quote for the day the money will land.

Allocation is not selectable. A payment is applied to the oldest open loan first and moves to the next only once that loan is fully repaid; within a loan it retires accrued interest, then the disbursement fee, then principal. Because interest is retired first, a loan cannot reach zero principal with interest still owing. Overpayment beyond everything outstanding is carried forward as credit. A loan on hold for a dispute is passed by unless the payment names it.

What a payment can reach. Only loans DayZero carried on its date: never a loan whose transaction is dated after paid_on, and never an imported loan before its import date. That holds when the payment is first applied and every time a backdated payment re-applies it. What it leaves over is credit, and a loan originated later takes that credit at its own origination, as it would have had the payment been recorded on time.

Before you debit, ask what an amount would do. POST /credit/payments/preview takes credit_line_id (or external_line_id), amount_cents, an optional as_of (default today, UTC) and an optional loan_id, and returns what recording that payment would retire:

bash
curl -X POST https://api.ondayzero.com/api/v1/credit/payments/preview \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"credit_line_id": "LINE_ID", "amount_cents": 3000000, "as_of": "2026-04-15"}'

The response lists allocations, one per loan reached (loan_id, external_settlement_id, merchant_name, and the interest_cents, fee_cents and principal_cents it clears, with loan_paid_in_full), then unapplied_cents (what exceeds everything owing, which would be carried forward as credit), balances_after (total_cents, principal_cents, fee_cents, interest_cents, credit_balance_cents) and loans_remaining_open. The preview brings interest up to as_of and allocates exactly as the real call does, including a backdated as_of; the figures are what POST /credit/payments returns for the same amount and date. The per-loan rules apply to a preview with loan_id as they do to a payment: below the minimum, or not an open loan on the line, is 400. A preview records nothing, emits no event and calls no webhook; call it as often as the dialog needs. Under cycle_draw programs, allocations are per cycle (cycle_id, with loan_id null).

  • Per-row Pay: pass loan_id to allocate to one loan; the program's per-loan minimum applies, or the outstanding amount when smaller. The loan must be open on paid_on. A payment dated before a later payment, adjustment or dispute resolution paid the loan off is a correction and is accepted: those later events are lifted (see backdated payments below), the loan is open again on that date, and the minimum is checked against it then. Otherwise a loan_id that is not on the line, was not yet originated (or, for an imported loan, not yet imported) on paid_on, is written off, or was paid off on or before paid_on is 400 (CREDIT_529), as is one a refund paid off (a refund keeps its place in a replay). A pending debit must name a loan open now. Nothing is recorded on a refusal: no payment row exists afterwards, so the same external_payment_id can be sent again with a correct loan. Quote one loan with payoff-quote?loan_ids=LOAN_ID.
  • Pending debits: pass "pending": true to record an initiated debit without allocating, then confirm with POST /credit/payments/{id}/settlement and settled_on. The payment counts from the settled date. Sending the settlement again with the same settled_on returns the payment as settled and allocates nothing a second time, so a settlement that timed out is safe to repeat with its date. A different settled_on on a payment already settled is refused with 400 CREDIT_731, which names the date it settled on: the payment was allocated as at that date, and re-dating it would leave the two apart. On a program with bank_settlement_matching on, the bank feed can confirm it for you (below). By then the money has moved, so a settlement is always booked at settled_on, however long ago: where a later adjustment or partial write-off can no longer apply in full after it (a fee waived from 3 April that a debit landing on 2 April already paid), that adjustment or write-off is re-applied for what is then outstanding instead of refusing the settlement. The adjustment keeps requested_amount_cents beside the amount_cents that took effect, the register entry's memo says why, and the journal entry for the part that no longer applies is reversed. The bank matcher and an operator's confirmation of a match settle the same way.
  • Backdated payments: a payment dated before the last processing date removes the accrual days after it and re-accrues them on the reduced principal. Everything applied to the line with a later date is lifted first and applied again after it, in date order (then in the order it was recorded): later payments, fee and interest waivers and goodwill credits, dispute holds and dispute resolutions. A later payment then retires interest only on principal still owed; a dispute reversal takes back the interest accrued before the hold on the principal the earlier payment left, and credits what had been paid towards it; a loan whose hold had not yet opened on the payment's date is in the waterfall. The register records each lift and re-application as an adjustment entry; the GL entry of a later payment or resolution is reversed and posted again only where its figures changed. A loan originated after the payment's date takes carried-forward credit again at its origination, oldest credit first, from the credit that had arisen by its transaction date: credit the earlier payment makes (or leaves a later payment with) is consumed where it would have been, and only a change is posted (one credit_applied register entry and journal entry). A partial write-off dated after the payment is lifted too, so the principal it wrote off accrues for the days before it, and is written off again on its date. If a later adjustment or partial write-off can no longer apply — a waiver larger than what the earlier payment leaves in its bucket, a write-off of more principal than it leaves — the payment is refused with CREDIT_650 or CREDIT_653, which names it, and nothing is recorded: date the payment on or after it. A dispute resolution recorded before DayZero kept how its fee reversal split, on a program with deferred fee recognition, refuses it the same way (CREDIT_651). (A pending debit settling is never refused this way, CREDIT_651 included; see above.) Refunds keep their place: they take effect when they are posted, and a backdated payment's replay does not lift them. A refund dated before its loan's last accrual is backdated itself, and re-accrues its own loan when it arrives: the days after the refund's date are removed and accrued again, through the day the loan had reached, on the principal the refund leaves, as a backdated payment does. It does so only while nothing else has reached that loan since the refund's date (a payment dated or returned after it, another register entry on the loan such as an adjustment, a write-off or credit, or a dispute opened or resolved after it), because re-accruing those days would undo what that later event did to them; then the refund reduces the loan's principal as before and the days already accrued stand. A loan written off in full is closed to the replay: nothing backdated reaches it, and what a later payment had paid it stays exactly where it landed (its write-off was worked out from it), so only the rest of that payment is applied again.
  • Returns: POST /credit/payments/{id}/reversal with reversed_on and reason reopens the loans at the balances they held. The merchant is not charged interest for the interval the debit was in flight: from the day after the payment's date through reversed_on, a loan the payment closed accrues nothing and a loan it only reduced accrues on the reduced principal; the restored balance accrues from the day after reversed_on, as a payment's own reduction counts from the day after its date. A payment that left money over takes back exactly that credit and nothing else (see Credit balance), and never re-opens a loan the merchant has since won a dispute on, whether the payment paid it directly or through credit it consumed. A statement the payment had paid reads issued again, with its remaining figure and the line's amount_due_cents restored, and the register's payment_reversal entry carries reason as its memo. A loan the return leaves owing past its due date is past_due at once and, unless it is on hold or was announced before, gets its credit.loan.past_due event then (see Events).
  • AutoPay: where autopay_enabled is true, the daily sweep emits one credit.autopay.due event for each outstanding loan that is due (its due_date is on or before the sweep date) and has not had one, carrying the amount to debit as at that day. A loan normally gets it on its due date; a loan already past due when AutoPay is switched on, or due on a day the sweep did not run, gets it on the next sweep. Each loan gets one, ever (the event's idempotency key is autopay.due:{loan_id}), so a debit that fails is not asked for again: retry it from your side, or take a payment another way. Execute it and report it as a payment. A loan on hold gets none until the hold is released.

GET /credit/lines/{id}/payments lists payments newest first. On every read of a payment — the response to recording it, the same payment reported again, and this list — unapplied_cents is what it carried forward as credit: its amount less what its allocations retired, including the whole amount when it reached no loan. A pending payment has not been applied yet and a returned one's credit was taken back, so both read 0.

Credit balance

A line carries credit when the merchant is owed money: a payment beyond everything outstanding, a refund larger than what its loan still owed (or of a loan already repaid), or a dispute won by the merchant (or a void) on money they had already paid. It is never paid out; the next loan the line originates consumes it. DayZero records each deposit of credit as an entry with its source, and each loan that consumes credit draws on the oldest entry first and is named on it.

plaintext
GET /credit/lines/{id}/credit-balance?status=open

The response carries credit_balance_cents (the dashboard figure), entries_remaining_cents (the sum of the entries' remaining_cents: equal to it less unattributed_cents), unattributed_cents, open_entry_count, and the entries newest first, paged (items, next_cursor, has_next, limit; filter with status, comma-separated). Each entry has:

Field Meaning
source overpayment, refund, dispute, or opening (below)
source_payment_id, source_settlement_id, source_dispute_id The payment, refund settlement or dispute that made it
source_reference Your id for it: the external_payment_id, the refund's external_settlement_id, or the issuer's dispute id (the dispute id when there is none)
created_on The date the credit arose. Loans consume the oldest first
amount_cents Credit the source made, in total
consumed_cents What loans hold of it now
withdrawn_cents What was taken back because its source was not real (below)
remaining_cents amount_cents less consumed and withdrawn: still available
status open, consumed or withdrawn
consumptions Each loan that consumed part of it: loan_id, external_settlement_id, amount_cents, on, and withdrawn_cents / withdrawn_on once taken back

What a returned payment takes back. Exactly the credit that payment made, and from where it went. The part no loan has consumed is withdrawn. The part loans consumed is taken back from exactly the loans its entry lists, newest loan first: each owes that amount again (a credit_applied register entry and, with GL posting on, the reverse of the credit-applied journal entry). Credit from refunds and disputes, and credit from other payments, is not touched. A loan since written off keeps what it consumed; that part is not recovered. A backdated payment that leaves a later payment with less over withdraws the difference from that payment's credit in the same way, and one that leaves it more over adds to its entry; the same holds for a dispute resolution it replays.

A loan the merchant won a dispute on. When the merchant wins a dispute on a loan that consumed credit (or the loan is voided), the reversal takes what is still owed off the loan and turns the part the credit had paid into the dispute's own credit. If that credit's source is then taken back — its payment returned — the dispute credit made from it was never real either: the withdrawal comes off the dispute's entry, not the loan, which stays at zero; the reversal then reads as having taken that principal off the loan itself (a credit_applied and an adjustment register entry that net to nothing on the loan, and no journal entry), and the dispute's credited_cents falls by the same amount. Where a later loan had already consumed that dispute credit, that loan owes it again. The entries' consumptions and withdrawn_cents record every step, and a loan without such a dispute is treated exactly as before.

The same holds for a returned payment that paid such a loan directly. The merchant had paid part of the loan before winning, so the reversal took less off the loan and credited the rest. With the payment returned, the reversal takes that part off the loan instead, bucket by bucket (principal, fee and interest, up to what it credited of each): the loan stays closed for what the merchant won and owes again only what they would have owed without the payment. The return's payment_reversal entry restores the payment's allocation, an adjustment entry (from the dispute) takes the re-split part off again, the dispute's credited_cents falls by that part, and the dispute's credit entry gives it back: what no loan consumed is withdrawn, and a loan that consumed it owes it again. With GL posting on, the return's journal entry takes that part out of Customer Credit Balance rather than back onto the receivable. A payment applied after the resolution paid none of what it credited and re-opens the loan as any return does.

Credit from before entries were kept. A line that already held credit when entries began has its balance as one opening entry with no source. It is written the first time the line's credit moves (or by scripts/admin/credit/backfill_credit_balance_entries.py --apply, which does every line at once and is a dry run without --apply). What consumed credit before that is not reconstructed: a payment from before entries existed that bounces takes its credit back from the opening entry, then from the loans that consumed it, then from loans whose consumed credit no entry accounts for (read from the register). Until a line's opening entry exists, the endpoint reports its balance as unattributed_cents and lists no entries.

DayZero checks every night that the credit_balance_cents of each line with a register equals the sum of its entries' remaining_cents (see What runs on DayZero's schedule).

Code Status Meaning
CREDIT_404 400 cursor is not a cursor this list returned
CREDIT_650 400 A backdated payment was refused: an adjustment dated after it could no longer apply. The message names the adjustment; date the payment on or after it
CREDIT_651 400 A backdated payment was refused: a dispute resolution dated after it was recorded before DayZero kept how its fee reversal split (deferred fee recognition only). Date the payment on or after the resolution. A pending debit that has landed is never refused this way
CREDIT_653 400 A backdated payment was refused: a partial write-off dated after it could no longer apply. The message names it; date the payment on or after it
CREDIT_730 400 A payoff quote on a per-transaction line is dated before today and before the day the line's book is processed to. The message gives the earliest date it will quote
CREDIT_731 400 A settlement gives a settled payment a different settled_on. Send the date it settled on to read it back

The payoff quote also carries disclosure: the line's terms applied to the quoted principal as one single-payment loan made on as_of (see Disclosures and adverse action). It is null when nothing is outstanding.

Bank-matched settlement

When the money is visible in a bank feed DayZero already syncs, a pending payment does not need the settlement call: the debit showing up in the bank confirms it. Turn it on per program with bank_settlement_matching: true (PATCH /credit/programs/{id}, an operator action; off by default). Two kinds of account are read:

  • the line's linked repayment account (plaid_account_id on the line), where the merchant connected it through DayZero — on your business, or on the merchant's own DayZero business named in merchant_business_id. On a merchant's books only that account is read;
  • your own bank accounts, where a wire repayment lands with a sender name.

Every new bank transaction that could be a repayment goes through four rules, in order:

Rule When What happens
a. Linked account Money leaves the line's linked account, for exactly the amount of a pending payment on the line, dated within 5 days of its paid_on The payment is settled as at the bank date (settled_on and paid_on become that date)
b. Sender alias Money arrives on your account and its sender (the cleaned counterparty, else the bank description) is a recorded payment alias of the line — an exact match, as GET /credit/payments/resolve gives — with the same amount and window test The payment is settled as at the bank date
c. Return The same amount comes back on the account the payment was taken from — into the linked account, or out of your account a matched wire arrived in — within 10 business days, with a description that reads as a return (NSF, RETURN, REVERSAL, an ACH return code such as R01) The payment is reversed as at that date with reason ach_return, exactly as POST /credit/payments/{id}/reversal would
d. Ambiguous A rule finds more than one payment that fits Nothing is applied. The transaction is recorded ambiguous on each line it could belong to, for an operator to decide

A deposit on your account that fits a pending payment by amount and window but whose sender is not a recorded alias is never applied on the amount alone. It is recorded as an unmatched_candidate on the lines it could be (at most five) and looked at again on every later pass, so recording the sender as an alias lets the next pass settle it. Business days skip weekends; bank holidays are not modelled.

Settling and reversing go through the same path as your own calls, so the allocation, the ledger, the spend-limit push, and the credit.payment.settled / credit.payment.reversed events are identical. Matching runs whenever the bank feed syncs, and the nightly sweep re-reads the last 16 days, which catches a payment recorded after its debit had already synced.

GET /credit/lines/{id}/payment-matches lists every decision for a line, newest bank date first: kind (settled, returned, ambiguous, unmatched_candidate), the rule that decided (linked_account, alias, ach_return), matched_on (the bank date applied), amount_cents, the bank description, notes saying what matched, candidate_payment_ids when more than one payment fit, and a confidence between 0 and 1 that is informational only. Filter with kind (comma-separated) or unresolved=true for the rows waiting on an operator. A transaction is decided once; running the matcher again never applies it twice.

An operator decides the open rows in the console (Payments tab, Payment matches) or with:

plaintext
POST /credit/payment-matches/{id}/override
{ "action": "confirm", "payment_id": "...", "note": "Matches the Payliance trace" }

confirm settles the payment as at the bank date — or, on an ach_return row, reverses it. payment_id is needed when the row does not name a single payment; it must be on the row's line, for the bank amount, and pending (or, for a return, already applied). Confirming closes the same transaction's open rows on other lines as superseded. reject records that the transaction is not this line's payment, and it is not offered again. A row that already settled or reversed a payment cannot be overridden: if the money did not land, reverse the payment.

Code Status Meaning
CREDIT_301 404 No such match on this business
CREDIT_302 400 The match was already confirmed, rejected or superseded
CREDIT_303 400 The match already settled or reversed its payment; reverse the payment instead
CREDIT_304 400 payment_id is required: the match names no single payment
CREDIT_305 400 The payment is on another line
CREDIT_306 400 The payment's amount is not the bank amount
CREDIT_307 400 The payment is not pending (to settle) or not applied (to reverse)
CREDIT_310 400 cursor is not a cursor this list issued

A worked example

Five transactions post in March on a 30-day term with a 1% fee at 20% APR. Nothing accrues in March; the March statement bills $45,450.00 of principal and fees. By 15 April two loans are past their due dates and have accrued $72.33 and $23.29. A $30,000 payment on 15 April retires those two loans and a third in full (interest, fee, principal), clears the fee on the fourth and pays $6,824.38 of its principal, and does not touch the fifth. The April statement bills $15,581.97: the remaining principal, the fifth loan's fee, and $36.35 of interest accrued to 30 April.

Underwriting

Request an assessment during the application; it returns pending, already carrying waiting_on, wait_deadline and ready_at (below), worked out when the request is made. Bank backfill, categorisation, and enough history for trailing ratios can take up to three days. Poll GET /credit/underwriting/assessments/{id} for status, completed_at, and failure_reason, or watch the event feed for credit.underwriting.completed.

The nightly sweep measures a pending assessment only once the merchant's data has landed: a bank connection exists and its first Plaid sync (the historical backfill) has finished, the categoriser has been over the imported transactions (no more than 2% still waiting for a first pass), and, when a Shopify store is connected, its first sync has finished. When the scorecard has a chargeback rule (shopify_chargeback_rate), a store's sales are not enough: the sweep also waits until a finished sync has pulled the twelve months of disputes the rule counts, the same test that leaves the rule unmeasured (dispute_history_incomplete) until then. Until then the assessment stays pending and says what it is waiting on:

Field Meaning
waiting_on bank_connection, bank_backfill, categorisation, shopify_sync, shopify_disputes: what the sweep is still waiting for, as checked when the assessment was requested and again at each nightly run. On a completed assessment, what was still missing when it completed. Empty when nothing was missing
ready_at When the merchant's data was first found complete, at the request or at a nightly check. Null while waiting, or when the assessment completed without it
wait_deadline requested_at plus the program's underwriting_wait_days (default 3), set when the assessment is requested. From then on the sweep completes the assessment with whatever data exists; a rule whose figure never arrived is unmeasured, not failed

POST /credit/underwriting/assessments/{id}/run does not wait: it measures and completes at once, and records what was still missing in waiting_on. It is open to any token on a sandbox business and an operator action on a live one (an API token receives 403 AUTH_057; see Operator actions). The record behind these fields is financials._readiness, which also carries completed_because (data_ready, deadline or on_demand) and the counts the check read.

DayZero measures every criterion on the program's active scorecard that has a registered measurer from the categorised ledger, the bank feed, Shopify, and the debt schedule, and returns per-criterion value, threshold, comparator, passed, source, and evidence, plus a weighted score_bps. passed: null means the figure could not be measured yet, which is different from a failure; unmeasured_reason says why. decision is always null: the lending decision is yours.

Send what is being applied for with the request:

plaintext
POST /credit/underwriting/assessments
{ "program_id": "...", "external_application_id": "tie_app_00417", "requested_limit_cents": 25000000, "requested_term_days": 60 }
Field Meaning
requested_limit_cents The limit applied for, in cents. Rules written as a multiple of the limit resolve against it, and rules that apply only in a limit band use it to decide. Without it those rules come back passed: null with unmeasured_reason: "requested_limit_missing"; they are never failed
requested_term_days The term applied for
scorecard_version_id Assess against this version instead of the active one. Must be approved, active or retired; a draft is refused
subject_business_id The business whose data is measured. Defaults to the line's merchant_business_id, else the business in x-business-id. Must be one your token can access. Send it (or request against a line that has merchant_business_id) for every merchant assessment: without either, DayZero measures the operator's own books, which only makes sense for the credit-book measures (credit_utilisation_90d, credit_repayment_performance_180d). The response's subject_business_id is null exactly when that happened
criteria_override Evaluate against these criteria instead of a scorecard. Recorded on the assessment as an ad-hoc snapshot with no version
credit_line_id A line of yours the application is for (a re-assessment). Its program decides the scorecard when program_id is not sent, and its merchant_business_id is the default subject. A line that is not one of yours is 404

The request is refused before anything is recorded when there is nothing to measure it against: a program_id that is not one of your programs is 404, and when neither the request (scorecard_version_id, criteria_override) nor the program (underwriting_criteria or an active scorecard version; none when the request names no program and no line) gives criteria, the call is 409 CREDIT_POLICY_UNDECIDED (an assessment opened then could never complete). Activate a scorecard version (see The scorecard), or name one.

Send external_application_id and the request is safe to repeat: a second request with the same external_application_id for the same credit_line_id (or, with no line, for the business) returns the assessment the first one opened, in whatever state it has reached, and opens nothing. Completing an assessment closes it: POST /credit/underwriting/assessments/{id}/completion on an assessment that is already completed or failed is 409 with code: "CREDIT_556", as deciding a limit review twice is refused. Read the assessment instead of completing it again. Completion scores figures the caller supplies, so, like /run, it is open to any token on a sandbox business and an operator action on a live one (an API token receives 403 AUTH_057).

Each criterion in the response carries, beside the fields above: threshold resolved for this application (a 0.5 × limit rule at a $250,000 limit reads 125000), threshold_basis and threshold_value as written on the scorecard, required as it applies to this application, and applicable (false when the rule's applies_when excludes the application; such a rule is not scored). The response names what it ran against in scorecard_version_id and scorecard_version_number (null for an override), so the result can be reproduced later even after the scorecard changes.

A completed assessment carries adverse_action_reasons: the criteria that were measured and failed, as criterion_key, label, measured, threshold, comparator, direction (below, above, not_equal, equal, outside), required, weight and contribution_bps (the criterion's weight over the points the application could earn). Required criteria come first, then the heavier weights. A pending assessment, an unmeasured criterion, and a rule that did not apply are never reasons. These are structured fields only, with no wording: GET /credit/underwriting/assessments/{id}/adverse-action returns the same list with a sentence per reason, which is the text a notice uses; see Disclosures and adverse action.

required_failed lists the measure keys (trailing_12m_revenue, …) of the required criteria that failed, each key once even when two rules on the same key failed. To see which rule failed, read criteria[]: each entry has its id, label, required and passed.

grade is set when the scorecard has grade bands. manual_checks lists the policy items a person reviews (restricted industries, lien searches, and the like), each pending, attested, exception, or not_applicable. Attest one with POST /credit/underwriting/assessments/{id}/manual-checks/{check_id} ({ "status": "attested", "note": "...", "attachment_s3_key": "..." }), or send manual_checks with the completion call (an operator's, on a live business). attachment_s3_key is the s3_key that POST /api/v1/upload/direct returns for the uploaded evidence; that endpoint is outside the credit surface, so a credit-scoped token cannot call it, and a check that requires evidence is attested in the console or with an unscoped token. Manual checks are reported beside the score. They never change it and never set a decision.

Limit reviews

A line opens at the limit you approved. With a limit_review_policy on the program, DayZero re-derives each line's limit on a cadence from the same measurers an assessment uses, records what it found as a review, and either proposes the new limit for an operator or applies it.

json
"limit_review_policy": {
  "enabled": true,
  "cadence": "weekly",
  "formula": { "basis": "trailing_3m_revenue", "pct_bps": 2500 },
  "floor_cents": 1000000,
  "cap_cents": 50000000,
  "max_step_up_bps": 2000,
  "max_step_down_bps": 5000,
  "min_change_cents": 100000,
  "triggers": [
    { "key": "shopify_refund_rate", "op": "gt", "value": 0.08, "action": "step_down_bps", "amount": 2500 },
    { "key": "cash_runway_months", "op": "lt", "value": 2, "action": "freeze" }
  ],
  "apply": "proposal"
}
Field Meaning
enabled Reviews run only when true. Default true
cadence weekly (once per ISO week, Monday to Sunday) or monthly (once per calendar month). Default monthly
formula.basis The measure the limit is sized on: trailing_3m_revenue (revenue ledgers, 90 days), shopify_trailing_90d_sales (Shopify gross sales, 90 days) or trailing_12m_ad_spend (Advertising & Marketing ledger, 365 days)
formula.pct_bps Share of the basis the limit is set at. 2500 = 25%. 1 to 100,000
floor_cents, cap_cents The lowest and highest limit a review recommends. Each must sit inside the program's limit_min_cents–limit_max_cents; left out, the envelope is the bound. floor_cents cannot exceed cap_cents
max_step_up_bps The largest increase one review may make, in basis points of the current limit. 2000 = at most 20% up. Left out: no limit
max_step_down_bps The largest formula-led decrease, in basis points of the current limit (up to 10,000). Triggers are not held to it. Left out: no limit
min_change_cents A recommendation closer than this to the current limit is recorded as no change. Default 0
triggers Up to 20 risk conditions. key is any measure from GET /credit/underwriting/measurers; op is gt, gte, lt, lte, eq or ne; value is in the measure's own unit (0.08 = 8% for a ratio, whole currency for money). action is step_down_bps (with amount, the cut in basis points of the current limit) or freeze (no amount)
apply proposal (default): every change waits for an operator. direct: a change is applied as soon as the review runs. Allowed only when the program's limit_change_policy is direct

The policy is checked when the program is saved: a floor or cap outside the envelope returns 400 (CREDIT_061), and so does narrowing the envelope under a stored policy; apply: direct on a program whose limit_change_policy is not direct returns 400 (CREDIT_062); a trigger on a measure DayZero does not have returns 400 (CREDIT_063). Send limit_review_policy: null to turn reviews off and drop the policy, or enabled: false to keep it.

How the limit is worked out

Always in this order, and every stage is recorded in the review's evidence.steps:

  1. Formula. The basis × pct_bps, rounded down to whole dollars. When the basis cannot be measured, the current limit is the starting point and the formula is skipped.
  2. Clamp. Into the floor and cap, and the program envelope.
  3. Steps. No more than max_step_up_bps above the current limit, no more than max_step_down_bps below it.
  4. Triggers. A step_down_bps trigger that holds caps the limit at the current limit less its cut; when several hold, the largest cut wins. A freeze trigger allows no increase. The floor and the envelope still hold after a cut, and a frozen line is never lifted above its current limit, even to reach the floor.

A result within min_change_cents of the current limit is no_change. A trigger whose measure cannot be read does not fire.

Worked example, with the policy above, on a $250,000 line: trailing three-month revenue of $1,200,000 gives $300,000; that is inside the $10,000–$500,000 floor and cap, and exactly the 20% step allowed, so the review recommends $300,000. If the Shopify refund rate were 11%, the refund trigger would cap it at $250,000 less 25%, so the review recommends $187,500. If cash runway were 1.4 months, the freeze would hold it at $250,000: no change.

freeze is not a suspension. The line, its cards and its spending are untouched; the review simply cannot raise the limit, and any open proposal on the line is withdrawn.

What a review reads

The basis and any trigger on the merchant's own books (revenue, ad spend, Shopify, bank) are read from the line's merchant_business_id. A line not linked to the merchant's DayZero business has those measures reported unmeasured with reason_code: "no_merchant_business", never read from your books, so its formula leaves the current limit standing. The two credit-book measures, credit_utilisation_90d and credit_repayment_performance_180d, are read from the line itself and work on every line. Each review's evidence.measurements holds every measure it read, with the same evidence an assessment shows.

Proposals and direct apply

A review that changes the limit is applied when the policy says apply: direct and the program's limit_change_policy is direct; otherwise it is proposed. A direct apply goes through the same path as PATCH /credit/lines/{id}: the limit is checked against the envelope, one credit.line.limit_changed event is emitted (its payload carries limit_review_id), and the new available credit is pushed to the issuer once.

Decide a proposal:

plaintext
POST /credit/limit-reviews/{review_id}/decision
{ "decision": "apply", "limit_cents": 28000000, "reason": "Credit committee, 3 June" }

decision is apply or dismiss; reason is required; limit_cents applies a different figure than the one recommended (it must sit in the envelope). Who may decide follows the program's limit_change_policy:

limit_change_policy Who may apply or dismiss
direct Any caller that can reach the line, an API token included
approval_required A signed-in operator in the console (an API token gets 403). The person who ran a review by hand cannot also apply it (400, VALIDATION_479); a review the nightly sweep made can be applied by any operator
console_only A signed-in operator in the console (an API token gets 403)

A decided review is final: deciding it again returns 409 (CREDIT_065). Applying a proposal after the line's limit has moved returns 409 (CREDIT_066); run a new review. Applying the limit the line already has returns 400 (VALIDATION_480). An unknown review id returns 404 (CREDIT_067). A newer review always replaces an open proposal on the same line: the old one becomes dismissed with the reason "Superseded by the review of …".

Status Meaning
proposed Waiting for an operator
applied Written to the line; applied_limit_cents says what
dismissed Turned down, or replaced by a newer review
no_change The policy arrived at the current limit, or within min_change_cents of it

A review is DayZero's recommendation; a change someone asks for is a limit-change request, decided on the same tab under the same limit_change_policy. The two are separate records and do not replace each other: a pending request survives a new review, and an applied review does not touch a pending request (approving it afterwards sets the line to the figure the request names). See Limit changes.

Running and listing reviews

The nightly sweep reviews every active line (a suspended or closed line spends nothing, so it is not reviewed) whose program has an enabled policy and that has no review dated in the current cadence window; a review run by hand in the window counts. POST /credit/lines/{id}/limit-reviews/run (body {}, or {"as_of": "2026-06-01"}) runs one now whatever the cadence says, and is open to your token; it returns 400 (CREDIT_064) when the program has no enabled policy or the line is not active, and refuses a future as_of on a live business. GET /credit/lines/{id}/limit-reviews lists a line's reviews newest first.

Each review carries as_of, source (sweep or manual), cadence_window (2026-W23 or 2026-06), status, current_limit_cents, recommended_limit_cents, change_cents, applied_limit_cents, basis_key, basis_value (whole currency, null when unmeasured), frozen, triggers_fired (each with the figure measured), evidence, requested_by (null for the sweep), decided_by, decided_at and reason.

Events

credit.line.limit_review is emitted when a review proposes or applies a change, and again when a proposal is applied or dismissed (a superseded proposal included). The payload carries limit_review_id, credit_line_id, external_line_id, as_of, status, current_limit_cents, recommended_limit_cents, applied_limit_cents, basis_key, basis_value, frozen, triggers_fired (key and action), decided_by and reason. A review that changes nothing emits nothing. The limit itself only ever moves with a credit.line.limit_changed event.

In the console

The line's Limit reviews tab lists the line's limit-change requests first (see Limit changes), then every review with the limit when reviewed, the recommendation and the change, the basis, the triggers that fired, and the measurements and working behind it. A proposal has Apply (with the option to apply a different figure) and Dismiss, each asking for a reason; Review now runs one on the spot. The policy is edited in the program form under Limit reviews: cadence, basis and percentage, floor and cap, step limits, the smallest change worth making, whether a change is applied or proposed (proposed only, when the program's limit changes need an operator), and the triggers as rows.

Error codes

Code Status When
CREDIT_061 400 A policy's floor or cap is outside the program envelope
CREDIT_062 400 apply: direct on a program whose limit_change_policy is not direct
CREDIT_063 400 A trigger names a measure DayZero does not have
CREDIT_064 400 A review was asked for on a line whose program has no enabled policy, or a line that is not active
CREDIT_065 409 The review is not proposed
CREDIT_066 409 The line's limit changed since the review ran
CREDIT_067 404 No such limit review
VALIDATION_479 400 Under approval_required, the person who ran the review tried to apply it. Limit-change requests use the same code for their requester; see Limit changes
VALIDATION_480 400 Applying the limit the line already has. Limit-change requests use the same code; see Limit changes

Cards

Cards are issued through Ramp; DayZero orchestrates and mirrors, and card credentials never transit DayZero. POST /credit/cards/{id}/embed-token mints a short-lived issuer token so the issuer renders the number inside its own component; each disclosure is audited (credit.card.credentials_requested, at most one per card per minute). The card must be active (400 otherwise). When the issuer answers without a token the call is 503 CREDIT_700 rather than an empty token that would render an empty component; nothing was disclosed, so no event is recorded. Try again shortly.

A card can also be issued in the call that opens the line: send card with POST /credit/lines (see Open a credit line); it goes through this same path, keyed line-open:<line id>.

POST /credit/cards with an idempotency_key issues one card per key per business. A repeat returns the card already issued under the key, on whichever line it was issued (the issuer deduplicates the key the same way), and a repeat that arrives while the first is still at the issuer waits for it; neither calls the issuer again. A failed attempt does not hold its key: sending it again issues. Suspending a suspended card (POST /credit/cards/{id}/suspension), lifting the suspension of an active one (DELETE on the same path) and terminating a terminated card (DELETE /credit/cards/{id}) return the card as it is, with no issuer call and no second credit.card.suspended or credit.card.terminated. Two such calls at once may both reach the issuer, which sets the same state, but record one change and one event, and both return the card as the first left it. Suspending a card the merchant froze from the portal, or collections suspended, takes the suspension over (suspended_via becomes operator) without calling the issuer, so only an operator lifts it.

After every origination, repayment, reversal, and write-off, DayZero pushes available credit to the issuer as the card's spend limit, so the card declines at the limit. Only an active card takes the push, so lifting a card's suspension (by an operator, by the merchant on the borrower portal, or by collections releasing it) pushes its line's current figure at once: the card comes back with the limit its line has now, not the one it had when it was suspended. POST /credit/lines/{id}/cards/spend-limit-sync forces it; POST /credit/cards/sync reconciles state and limits against the issuer and reports drift.

Every update DayZero sends to a card's Ramp fund carries the card's whole set of spend controls: the limit and its interval, the allowed categories and the blocked MCCs (an empty list sent as empty), and the per-transaction cap and lock date when the card has them. A limit push, an allow-list apply and PATCH /credit/cards/{id}/limits each send the full set, built from the card's stored controls and the change, so none of them can clear what another set, whether Ramp merges an update or replaces it.

The limit is a lifetime (TOTAL) limit at the issuer, which counts everything ever spent on the card, so the figure pushed is the card's spend to date plus the line's available credit. A card's spend_limit_cents is therefore that ceiling, not the headroom; GET /credit/lines/{id}/cards/monitor shows both, with available_cents as what the card can still spend. Spend is attributed to a card by the card_last_four on each settlement, so send it.

Merchant categories. A program's allowed_mcc_codes and blocked_mcc_codes are MCCs (the four-digit codes on every card transaction). At the issuer, Ramp blocks by MCC but allows only by its own category codes, so the allow list is pushed as the Ramp categories those MCCs fall under (advertising, SaaS / software, cloud computing, and so on). An MCC with no known Ramp category is reported and left out of what is pushed rather than guessed.

MCC allow list

The allow list is built from one of three sources, each previewed as the same table before anything changes:

Source How Use it when
Upload multipart/form-data with the card transaction export as file (CSV, or JSON: an array of objects or an object holding one under transactions, data, items or rows), optional amounts_in (dollars, the default, or cents) and min_count You have the cards' history from the current issuer
Learned JSON {"source": "settlements", "window_days": 90} (1 to 365): the program's own posted spend over the window. Refunds and voided transactions are left out A sandbox walk or the first month of live spend has run
Preset JSON {"source": "preset", "preset": "ad_spend"}: advertising (7311), software and SaaS (7372, 5734), digital goods (5815 to 5818), computer network and information services (4816, 7375) and business services (7399) A new program, so it is never blank

Both calls are operator actions in the console (an API token gets 403); send us the export and we preview it with you. Every source returns one row per MCC, highest spend first: mcc, description, transactions, spend_cents, up to five merchants, ramp_category_code and ramp_category_name (both null and unmapped: true when Ramp has no known category for it), suggested (mapped, and seen at least min_count times; every preset code), and currently_allowed. An MCC on the current list that the source never saw is added at the end with in_source: false, so dropping it is visible. The response also carries suggested_codes, the Ramp categories they span, unmapped_codes, rows_read and rows_without_mcc, and for an upload file_sha256. A file can also be tallied from the command line with scripts/admin/credit/mcc_allowlist_from_export.py, which reads the same formats and applies through the same call with --apply.

An uploaded export is staged and scanned for malware like every other credit upload before it is read, then read in a worker thread, counted as it goes, and refused at the first cap it passes: 10 MB, 200,000 rows, 500 columns (a JSON export may name 500 different fields in all), 10,000 characters in one cell, and, for JSON, which is read whole, 2,000,000 values. An amount is read as positive cents; one no ledger can hold (9e999990, Infinity) refuses the file with CREDIT_592 naming the row and column, while text that is not a number at all counts as no spend, as it always has. The command-line script reads a local file of any length.

POST /credit/programs/{id}/mcc-allowlist/apply with {"codes": ["7311", "7372"], "source": "upload", "file_sha256": "..."} writes the list (sorted, duplicates dropped) and pushes it to every live (active or suspended) DayZero-managed card on the program's lines. source is the preview it came from, or manual; an upload's file_sha256 is required and goes into the audit entry with the source, so the exact file behind the list is on record. The response lists each card pushed or failed with the issuer's reason; a failure does not undo the list, and applying again retries. Partner-managed cards are counted in partner_cards_not_pushed and never touched. To clear the list on purpose send {"codes": [], "source": "manual", "unrestricted": true}: that is recorded as a decision (mcc_allowlist_decided_at on the program), where an empty list with no date was never decided.

Off-list spend. The issuer is the control; DayZero is the alarm. When a settlement posts, by push or by the issuer's webhook, on a program with a non-empty allow list and its MCC is not on the list (or it has none), credit.settlement.off_allowlist is emitted once with the line, settlement, loan, MCC, amount and merchant, and reason (not_on_allowlist or no_mcc). The loan originates as usual. Refunds are not flagged. The nightly settlement reconciliation counts these as off_allowlist over its seven-day window.

In the console, the program form's MCC allow list section shows the current list and offers Upload card export, Learn from settlements and Use ad-spend preset; each opens the table with a checkbox per MCC (suggested ones ticked) and Apply.

Code Status When
CREDIT_590 400 The preview request does not validate (source, window_days, preset, min_count, amounts_in)
CREDIT_591 400 A multipart preview has no file part
CREDIT_592 400 The export could not be read: not UTF-8, not CSV or JSON, no MCC column, past a cap (columns, cell length, values), or an amount no ledger can hold. The message names the row
CREDIT_593 400 The export is larger than 10 MB, or has more than 200,000 rows
CREDIT_594 400 A code is not four digits
CREDIT_595 400 No codes and no unrestricted: true
CREDIT_596 400 unrestricted: true with codes
CREDIT_597 400 No code maps to a Ramp category, so the issuer would allow everything
CREDIT_598 400 file_sha256 missing or malformed with source: upload, or sent with another source
CREDIT_599 400 The preview was neither multipart/form-data nor application/json

Partner-managed cards

If you provision cards in the issuer yourself (card_management: partner on the program), DayZero never touches the card. Register each card once so cleared transactions can be attributed to the line:

plaintext
POST /credit/cards/register
{ "credit_line_id": "...", "provider_card_id": "<issuer card id>", "provider_fund_id": "<issuer fund id>", "last_four": "4417" }

Idempotent on the issuer card id: registering it again on the same line returns the card. An issuer card id is registered once across DayZero, so one already on another line, yours or another business's, is refused (400 CREDIT_507) without saying where. From then on, every balance change emits credit.line.limit_changed with available_credit_cents; push that to the issuer as the card's limit. Set the card's limit from the newest event: an event is emitted whenever the figures differ from the newest one already on the feed, including when headroom returns to an amount it has held before. DayZero pushes nothing to a registered card.

Both paths can run on one line at once. Each card is handled by who manages it: a registered card gets the credit.line.limit_changed event even on a dayzero program, and a card DayZero minted gets its limit pushed even on a partner program. The program's card_management is the default for what comes next.

Switching who manages the cards

POST /credit/programs/{id}/card-management with {"target": "partner"} or {"target": "dayzero"} moves the program's default safely. It is a dry run unless you send "dry_run": false, and returns the plan per line (who the line's limit goes to before and after) and per card:

Action Card What happens
keep Any Nothing changes for this card
adopt Registered, moving to dayzero The program's issuer connection can see the card's fund (and the fund carries the card): DayZero takes it over (managed_by: dayzero) and pushes its limit once
leave_with_partner Registered, moving to dayzero DayZero cannot reach it: no fund id registered, no issuer connection, the issuer does not know the fund, the fund is terminated, or it carries another card. reason says which. It keeps getting credit.line.limit_changed
stays_with_dayzero DayZero-issued, moving to partner The program stops pushing by default and starts the events, but this card stays managed_by: dayzero and DayZero keeps pushing its limit until it is terminated

Each card also says who manages it and who sets its limit after the switch (managed_by_after, limit_set_by_after), so no card is left without a limit. Applied, the switch writes card_management, adopts, pushes each adopting line once, and emits one credit.line.limit_changed (reason: card_management_changed, with card_management and card_managers) for every line whose managers changed, so you start from current figures; it is recorded in the audit log. Applying the same target again changes, pushes and emits nothing (applied: false). Moving to partner means new cards on the program are yours to provision and register; moving to dayzero means DayZero issues them.

Dropped settlement webhooks

For DayZero-managed cards, POST /credit/cards/reconcile-settlements ({"days": 14, "dispute_lookback_days": 30}) lists the issuer's cleared transactions per card and originates any it never received. It reads every active and suspended card, and a card terminated within the longer of days and dispute_lookback_days, since a late clearing, a void or a dispute can still arrive on what a card spent before it was terminated (a card the issuer terminated counts from the day DayZero's card sync first sees it so). The nightly sweep runs it over the last 7 days, plus the dispute look-back described under What runs on DayZero's schedule. A recorded settlement whose issuer state is no longer cleared is listed in voided_or_declined with the action taken: its loan goes on a void hold (hold_voided, on by default), reversed at once when nothing has been paid on it (voided) and otherwise left for an operator (held); send "hold_voided": false to only report it (reported). Disputes the issuer lists on a recorded transaction are applied the same way and listed in disputes; holds_placed counts both. See Disputes and voids. For partner-managed cards, re-push the settlement; ingest is idempotent on the settlement id.

Borrower portal

If you would rather not build the merchant's account pages yourself, DayZero hosts them. Mint a link for a line and send it to the merchant; it opens a mobile-friendly page branded as you, showing what they owe and when, their statements, and a payoff figure. The page is read-mostly and takes no payments: it tells the merchant to pay through you, and DayZero never moves money.

bash
curl -X POST https://api.ondayzero.com/api/v1/credit/lines/LINE_ID/portal-sessions \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"ttl_hours": 72, "scopes": ["schedule", "statements", "payoff", "autopay", "cards"]}'

The response is { "session_id", "credit_line_id", "url", "expires_at", "scopes" }. url (https://www.ondayzero.com/p/credit/<token>) holds a secret token and is returned once: DayZero keeps only its hash, so a lost link cannot be shown again; mint another. Anyone holding the URL can open the page until it expires or is revoked, so send it only to the merchant.

Field Meaning
ttl_hours How long the link works, 1 to 720 hours (30 days). Default 72
scopes What the link may show or do. Omit for the default (below)
Scope Allows
schedule The repayment schedule on the overview page: each purchase still owed, its due date, what it takes to pay it off today, and what it will take later
statements The statement list and each statement's PDF and CSV
payoff The payoff page
autopay Switching AutoPay on or off
cards Freezing and unfreezing the merchant's own cards
acknowledge Acknowledging a disclosure: the merchant's own legal act, which opens a pending line and applies a held limit increase. Reading the disclosure and its PDF needs no scope

GET /credit/lines/{id}/portal-sessions lists a line's links, newest first, with created_by (the DayZero user id, api_token, or system), expires_at, revoked_at, last_seen_at, view_count, scopes and active; tokens are never returned. DELETE /credit/portal-sessions/{id} revokes a link at once (revoking twice is harmless). An unknown, expired or revoked link shows the same "this link isn't available" page, so a link cannot be probed for whether it ever existed.

Without scopes, a link minted by a signed-in operator or a credit token carries schedule, statements, payoff, autopay and cards, and also acknowledge while the line has a disclosure awaiting acknowledgement (only a compliance program ever has one, so on any other program a link carries exactly those five, as it always has). A link minted while nothing waits does not carry acknowledge, and a link never gains a scope after it is minted, so links minted before acknowledge existed cannot acknowledge. To send the merchant a link that can, mint one after credit.disclosure.issued tells you a disclosure is waiting (requires_acknowledgement: true), or name acknowledge in scopes. The response's scopes says what the link carries.

A credit:read token may mint and revoke links, because a merchant dashboard is what that scope is for, but a link it mints carries the read scopes only (schedule, statements, payoff, its default), never acknowledge. Asking a read-only token for autopay, cards or acknowledge returns 403 (CREDIT_070). A credit token or a signed-in operator may grant every scope.

What each page shows:

Page Shows
/p/credit/<token> Balance, available credit, limit and next due date; a banner when anything is past due or the line is suspended; "What you owe and when" in plain words (the term, the fee, when interest starts and at what rate); with schedule, what is due in 7 and 30 days, what is past due, the payoff for everything, and one row per purchase; how to pay (your remittance_text); with autopay and cards, the switches below
/p/credit/<token>/statements Each issued statement with its period, balance and what is still owed of it, and PDF and CSV downloads (the same documents as GET /credit/lines/{id}/statements/{cycle_id}: the PDF is the statement as issued with "Since this statement" below it, the CSV is current). A statement rebuilt from the records is marked
/p/credit/<token>/payoff The payoff quote for today, or a date the merchant picks up to 90 days ahead, broken down by purchase, with the sentence "Pay this amount through ; the figure is good through ."
/p/credit/<token>/disclosure On a compliance program: the line's disclosure (the one waiting for acknowledgement, else the newest), its figures and the PDF as issued, on any live link; and, when it waits and the link carries acknowledge, an Acknowledge button (without the scope the page says to ask you for a link that can). The overview shows a banner linking here while a disclosure waits

The two things a merchant can change, when the link allows it:

  • AutoPay. Turning it on needs a linked repayment account (plaid_account_id or linked_account_mask on the line) and a line that carries debt: an active, pending or suspended line, or a closed one that still owes. Only a closed line with nothing left to collect refuses it. Turning it off always works. The change is the same as PATCH /credit/lines/{id} with autopay_enabled, so credit.autopay.due events follow as usual.
  • Card freeze. The merchant can freeze any active DayZero-managed card on the line, whatever the line's status, and unfreeze only a card they froze themselves, while the line can spend (it is active) and is outside the collections ladder. A card you or the collections policy suspended stays suspended; the portal says so and points the merchant to you. A card's suspended_via is borrower_portal while the merchant's own freeze holds, collections while a collections stage holds it, and operator when a suspension made through the console or the API holds it. Partner-managed cards are shown but cannot be changed here.

Both are recorded in the audit trail against the actor borrower_portal, and each page view bumps the session's view_count and last_seen_at.

What a merchant can do on a line that cannot spend. The portal sorts its actions by what they do. What helps the merchant pay works on any line that carries debt, so a merchant whose line is suspended, or closed while it still owes, can still see what is owed and when, open statements, get a payoff figure and switch AutoPay on or off. What lets the merchant spend (unfreezing a card) needs a line that can spend. Freezing a card stops spending, so it works on any line, and acknowledging a disclosure is recorded on any line: on a line that is suspended or closed it records the merchant's receipt and changes nothing on the line (see Acknowledgement).

Action Active Suspended Closed, still owes Closed, owes nothing
Overview, statements, payoff figure Yes Yes Yes Yes
AutoPay on Yes Yes Yes No (CREDIT_078)
AutoPay off Yes Yes Yes Yes
Freeze a card Yes Yes Yes Yes
Unfreeze a card the merchant froze Yes, outside the collections ladder No (CREDIT_078) No (CREDIT_078) No (CREDIT_078)
Acknowledge a disclosure (the link needs the acknowledge scope) Yes Recorded; the line stays suspended Recorded; the line stays closed Recorded; the line stays closed

Acknowledging a disclosure is the third form on the portal, protected the same way (the link's token, the same-site cookie and the matching hidden field), and a write like the other two: it needs the acknowledge scope (403 page otherwise, and nothing changes). See Acknowledgement.

Branding comes from the program: display_name heads the page, legal_lending_entity sits under it, and statement_branding supplies the logo (logo_data_uri only; the page loads nothing from other hosts), accent_hex, footer_text, remittance_text, and support_email and website when they are set. The only DayZero mark is a one-line "Serviced by DayZero." footer. The pages carry no script, are never cached or indexed, and protect the two forms with a same-site cookie and a matching hidden field.

Code Status When
CREDIT_070 403 A read-only token asked for the autopay, cards or acknowledge scope
CREDIT_071 404 No such portal session on this business
CREDIT_072 400 Schedule requested for a cycle_draw line
CREDIT_073 403 (page) The link's scopes do not include that page or action
CREDIT_074 403 (page) The form's CSRF token is missing or does not match
CREDIT_075 400 (page) AutoPay switched on with no linked account
CREDIT_076 400 (page) The card is partner-managed, or not active or suspended
CREDIT_077 400 (page) Unfreeze of a card you or collections suspended, or on a line in collections
CREDIT_078 400 (page) Unfreeze of a card on a line that cannot spend (pending, suspended or closed), or AutoPay switched on for a closed line that owes nothing
CREDIT_079 400 (page) Payoff date before today or more than 90 days ahead
CREDIT_609 400 (page) The disclosure was replaced by a newer one
CREDIT_610 400 (page) The disclosure does not need an acknowledgement
CREDIT_619 400 (page) The held limit increase can no longer be applied; nothing was recorded

Codes marked (page) are shown to the merchant as a message on the portal rather than returned to your integration.

Events

GET /credit/events is the polled feed. Pass the last sequence_no as after; sequence numbers are unique and gapless per business.

For a screen that wants the latest events, send order=desc: the newest limit events come back newest first, and next_cursor is the lowest sequence_no on the page. Pass it as before to read the page of older events; the last page has has_next: false. The two directions do not mix: after with order=desc is 400 (CREDIT_562) and before with the default order=asc is 400 (CREDIT_563). Ascending, the default, is unchanged, so an integration that polls with after needs no change. GET /credit/event-deliveries takes the same order and before, so a delivery column lines up with the page beside it. Program event_delivery_mode is polling (default: outbox only), webhook, or both. Signed POSTs to the endpoints registered under Webhooks fire only in webhook and both. Polling remains the source of truth after a missed delivery.

The feed notes who reads it: each ascending read made with an API token records the token's id, the after it sent and when, one record per token. The launch check events.feed_polled reads it (a program that polls fails when no token has read the feed in 24 hours). A signed-in console session, or a newest-first read (order=desc), records nothing. See Who reads the feed.

Event When
credit.line.opened A line was opened
credit.loan.originated A transaction posted and became a loan (also on import)
credit.loan.due_soon A loan is due_soon_days from its due date
credit.loan.past_due A loan passed its due date with an amount outstanding. Once per loan (key loan.past_due:<loan_id>), carrying past_due_since, by whatever road it got there: the nightly run the day after its due date, or at once when a returned payment, a refund, the end of a hold, a backdated payment's replay, or an import of a loan already past due leaves it past due. Not while the loan is on hold
credit.loan.paid A loan was repaid in full
credit.loan.written_off A loan was charged off
credit.loan.disputed A loan went on hold for a dispute or an issuer void; carries the dispute, its amount and opened_on
credit.loan.dispute_resolved A hold ended; carries outcome, hold_days, and the principal, fee and interest reversed and credited
credit.autopay.due AutoPay should debit a loan that is due; carries the amount. Once per loan
credit.cycle.closed A statement period closed and interest was applied
credit.statement.issued A statement is available
credit.statement.emailed The statement PDF was emailed to the merchant (email_statements programs). On a sandbox, where nothing is emailed to a merchant, it records the statement as kept and carries suppressed: "sandbox" and no message_id
credit.line.suspended, credit.line.reinstated, credit.line.closed A line changed status; see Line status
credit.collections.stage_changed A line entered, left, or moved between collections stages; carries previous_stage, stage (null on leaving), days_past_due, actions applied, oldest_due_date and as_of. One event per line, move and sweep date
credit.line.limit_changed Available credit changed on a partner-managed line, or the limit was changed; carries available_credit_cents to push to the issuer, and limit_review_id when a limit review made the change. reason: card_management_changed when a card-management switch changed who manages the line
credit.settlement.off_allowlist A settlement posted at an MCC outside the program's allow list, or with no MCC; carries the settlement, loan, MCC, amount, merchant and reason. The loan still originates
credit.line.limit_review A limit review proposed or applied a new limit, or a proposal was applied or dismissed
credit.cycle.paid A closed statement period was settled in full. Cycle-draw: when a payment clears the cycle. Per-transaction: when the period's outstanding_cents (as the statement list reports it) first reaches zero, after a payment is recorded or settled, an adjustment, a dispute resolution or a write-off. Carries cycle_id, period_start, period_end and paid_on. Once per cycle (key cycle.paid:<cycle_id>); if a payment reversal re-opens a per-transaction period, paying it again emits once more under cycle.paid:<cycle_id>:2, :3 and so on
credit.cycle.late A cycle-draw statement passed its due date unpaid and was marked late (cycle-draw programs only); carries due_date and outstanding_cents
credit.payment.recorded A payment was recorded
credit.payment.settled A pending payment settled and was allocated
credit.payment.reversed A payment was returned or failed
credit.underwriting.completed An assessment finished
credit.card.issued, credit.card.suspended, credit.card.terminated Card lifecycle
credit.card.reinstated A card suspension was lifted: by DELETE /credit/cards/{card_id}/suspension (API or console), by the merchant on the borrower portal, or by the collections ladder releasing a card it suspended when the line left a suspending stage. Carries card_id, credit_line_id and suspended_via (who had suspended it). Once per suspension lifted
credit.card.credentials_requested Card credentials were displayed
credit.disclosure.issued A disclosure was generated for a line (compliance programs only); carries disclosure_id, kind, trigger, jurisdiction, template_version, limit_cents, requires_acknowledgement, apr_bps and held_limit_cents, and on a terms change changed_terms (the program fields that changed)
credit.disclosure.acknowledged The merchant acknowledged a disclosure on the borrower portal; carries disclosure_id, acknowledged_at, line_activated, applied_limit_cents (a held increase that now applies) and not_applied_reason (line_suspended or line_closed when the line was stopped, so nothing was applied)
credit.disclosure.overridden An operator activated a pending line without the merchant's acknowledgement; carries disclosure_id, overridden_at, overridden_by_user_id, reason, line_activated and held_limit_cents (an increase it held, not applied)

Webhooks

Polling GET /credit/events stays the source of truth. A webhook is a prompt to act sooner, not a replacement: a delivery can be late, can arrive twice, and can arrive out of order, and an event your endpoint missed while it was down is still on the feed. Key your processing on the event id, and after any gap read the feed from the last sequence_no you stored.

Register an endpoint

bash
curl -X POST https://api.ondayzero.com/api/v1/webhooks/o2c \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/dayzero",
    "event_types": ["credit.loan.originated", "credit.payment.recorded", "credit.line.limit_changed"]
  }'

The URL must be public https; private, internal and cloud-metadata addresses are refused, and the check is repeated on every delivery. The response carries secret once: store it. To use your own, send secret (at least 32 characters). Any event in the table above may be subscribed to; an unknown type is refused with 400 CREDIT_101 naming it, never dropped silently.

Endpoints belong to the operator business, not to a program: every program on the business whose event_delivery_mode is webhook or both sends each event to every active endpoint subscribed to that event type. Register a second endpoint for a staging mirror.

Call What it does
GET /webhooks/o2c List endpoints. The secret is never returned
GET /webhooks/o2c/{id} One endpoint
PATCH /webhooks/o2c/{id} Change url, replace event_types, or set is_active. Omitted fields stay. Deactivating stops deliveries and gives up queued retries
DELETE /webhooks/o2c/{id} Remove the endpoint and its delivery history
POST /webhooks/o2c/{id}/test-fire?event_type=... Send a synthetic event to this endpoint (Test)
POST /webhooks/o2c/{id}/rotate-secret Mint a new secret (Rotate the secret)
GET /webhooks/o2c/{id}/deliveries Delivery history, oldest first. Filters: status, event_type, since (ISO timestamp). cursor / limit (default 50, max 100)
POST /webhooks/o2c/{id}/deliveries/{delivery_id}/redeliver Send one delivery again, a moment after the call (Redeliver)

A credit token may make every one of these calls and a credit:read token the GETs. Either sees and changes only endpoints that subscribe to credit.* events and nothing else, and cannot subscribe an endpoint to anything else: those calls return 403 CREDIT_103. The business's Order-to-Cash endpoints are not reachable with a credit token.

What a delivery looks like

plaintext
POST /dayzero HTTP/1.1
Content-Type: application/json
X-DayZero-Event: credit.loan.originated
X-DayZero-Delivery-Id: 0192f6c1-6b2e-7c4a-9d1f-3a8e5b7c2d10
X-DayZero-Timestamp: 1779285731
X-DayZero-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"id":"0192f6c1-6b2e-7c4a-9d1f-3a8e5b7c2d10","sequence_no":41207,"type":"credit.loan.originated","occurred_at":"2026-05-20T14:02:11.384021","credit_line_id":"...","program_id":"...","data":{"loan_id":"...","principal_cents":740000,...}}

The body is the event as the feed has it: id, sequence_no, type, occurred_at, credit_line_id, program_id, and data (the feed's payload). Answer with any 2xx at once and do the work after answering: an attempt has 15 seconds in all, from looking up your host to the first 1 KB of your answer, and one that runs past it is stopped and counted as a failed attempt (its error says so). Only that first 1 KB of the answer is read. A redirect is not followed and counts as a failure. X-DayZero-Delivery-Id is the event's id and is the same on every retry and redelivery, so a delivery whose id you have already processed can be acknowledged and dropped.

When a delivery is sent

A delivery is sent after the change that produced the event has been saved, never during it. When a settlement posts or a payment is recorded, the event and one pending delivery per subscribed endpoint are written together; the POST goes out once that write is committed, normally within a second. Two things follow:

  • An event you receive is always readable. By the time your endpoint is called, the event is on GET /credit/events and the loan, payment or line it describes can be fetched, so a receiver that calls straight back finds it.
  • An event that did not happen is never sent. If the operation fails and is rolled back, its deliveries go with it: nothing is posted, and nothing appears on the feed.

If DayZero stops between saving the event and sending it, the delivery is not lost: every minute, any delivery still pending ten seconds after it was written is sent, along with retries that have fallen due. Deliveries of one operation go out in the order their events were written, but across operations, retries, and that one-minute catch-up, arrival order is not guaranteed: order by sequence_no, and read the feed to fill a gap.

While an attempt is being made the delivery reads sending, and its next_retry_at is when that attempt is given up if it never reports (two minutes on). Nothing else sends a delivery while it is sending, so an attempt is never made twice. If DayZero stops in the middle of one, the attempt is counted as failed once that time has passed (error: the attempt was interrupted) and the next goes out on the retry schedule; your endpoint may have received the interrupted one, which is why X-DayZero-Delivery-Id stays the same. One slow receiver does not hold up the others: each delivery is sent on its own, the catch-up takes every endpoint's oldest deliveries first, and an endpoint whose attempt runs out of time waits for the next minute while the others are sent.

Verify the signature

X-DayZero-Signature is v1= followed by the hex HMAC-SHA256, keyed by the endpoint secret, of the X-DayZero-Timestamp value, a full stop, and the raw request body exactly as received. Verify before parsing, compare in constant time, and refuse a timestamp more than five minutes from your clock: the timestamp is inside the signature, so a captured request cannot be replayed later with a fresh one. Accept the request when either X-DayZero-Signature or X-DayZero-Signature-Previous matches, which keeps a secret rotation seamless.

python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret: str, headers, raw_body: bytes) -> bool:
    timestamp = headers["X-DayZero-Timestamp"]
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    expected = (
        "v1="
        + hmac.new(
            secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
        ).hexdigest()
    )
    candidates = [
        headers.get("X-DayZero-Signature"),
        headers.get("X-DayZero-Signature-Previous"),
    ]
    return any(c and hmac.compare_digest(c, expected) for c in candidates)
js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

// Node lower-cases header names. rawBody is the Buffer as received.
export function verify(secret, headers, rawBody) {
  const timestamp = headers["x-dayzero-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return false;
  }
  const expected = Buffer.from(
    "v1=" +
      crypto
        .createHmac("sha256", secret)
        .update(`${timestamp}.`)
        .update(rawBody)
        .digest("hex"),
  );
  return [headers["x-dayzero-signature"], headers["x-dayzero-signature-previous"]]
    .filter((sig) => typeof sig === "string" && sig.length === expected.length)
    .some((sig) => crypto.timingSafeEqual(Buffer.from(sig), expected));
}

Sign over the bytes you received, not over re-serialised JSON: with Express, mount express.raw({ type: "application/json" }) on the webhook route rather than express.json().

Retries

A delivery that does not get a 2xx (an error status, a timeout, a refused connection) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours: six attempts over 10 hours 36 minutes, after which the delivery is failed. Each delivery records the last response_code, the first 1 KB of the response_body, the error when nothing answered, latency_ms, last_attempt_at, and next_retry_at: when the next attempt is due, while one is. A delivery's status is pending (written, first attempt not made yet: see When a delivery is sent), sending (an attempt is being made now), succeeded, retrying, failed, or no_subscribers: the program pushes, but no active endpoint subscribed to that event, so nothing was sent.

On the feed, delivered_via_webhook is true only when an endpoint answered the first attempt with a 2xx; with no subscriber, or every endpoint failing, it stays false. It is set by that attempt, so for the second or so before it is made the event reads false. For the answer as it stands now, including retries and redeliveries, GET /credit/events/{id}/deliveries returns the event's delivery status and every delivery row behind it:

status Meaning
delivered Every endpoint it went to answered 2xx, on the first attempt, a retry, or a redelivery
pending The first attempt has not been made yet, or is being made; it goes out once the event is saved, normally within a second
retrying An endpoint has not answered 2xx yet and a retry is queued (next_retry_at) or being sent
failed An endpoint gave up. redeliverable lists the delivery to re-send per endpoint
not_subscribed The program pushes, but no active endpoint subscribes to this event
not_pushed Nothing was attempted: the program polls

When endpoints disagree the event takes the worst of them, in the order failed, retrying, pending, delivered. GET /credit/event-deliveries takes the feed's own parameters (after, event_types, credit_line_id, program_id, limit) and returns the same summary for each event on that page. The console's Events tab shows it as the Webhook column, with pending as Sending.

Redeliver

POST /webhooks/o2c/{id}/deliveries/{delivery_id}/redeliver sends the delivery's body to the endpoint again during the call and returns the new delivery with the attempt's outcome: its status (succeeded, or retrying with the receiver's response_code or the error), response_code and attempts. The new delivery is saved before the POST, so it is on record whatever happens to the call, and an attempt that runs past the 15 seconds comes back retrying with the reason. It is linked to the original by redelivery_of and carries the original's X-DayZero-Delivery-Id, so a receiver that already processed the event can drop it. A failed redelivery follows the retry ladder from the start. If the original was still waiting to be sent or retried, it stops there and is marked failed with the reason in error; one being sent at this moment is not redelivered (409 CREDIT_646): its outcome is not known yet, so read it again once the attempt has finished. A redelivery never changes the event's delivered_via_webhook. The endpoint must be active (400 CREDIT_102). In the console, a failed event on a line's Events tab has a Redeliver action that re-sends to every endpoint that gave up and says how each answered.

Rotate the secret

POST /webhooks/o2c/{id}/rotate-secret with {"overlap_minutes": 60} returns a new secret, shown this once. For the overlap (0 to 60 minutes; 60 when the body is omitted) each delivery is signed with both: X-DayZero-Signature with the new secret and X-DayZero-Signature-Previous with the old one, so a receiver still holding the old secret keeps verifying while you deploy the new one. The endpoint's previous_secret_expires_at says when the old secret stops signing. overlap_minutes: 0 retires it at once. Rotating again inside a window keeps only the secret just replaced.

Test

POST /webhooks/o2c/{id}/test-fire?event_type=credit.loan.originated sends a synthetic event to that endpoint only, signed like a real delivery, in the same envelope with "test": true, sequence_no 0 and empty data. Unlike a real event's delivery, the attempt is made during the call, so the response carries its status and response_code; a failed test is retried on the usual ladder, and the delivery appears in the endpoint's history like any other. The endpoint must be active.

Code Status Meaning
CREDIT_101 400 An event type no endpoint can subscribe to; the message names it
CREDIT_102 400 The endpoint is inactive: reactivate it with PATCH first
CREDIT_103 403 A credit-scoped token reached an endpoint, or named an event, outside the credit events
CREDIT_646 409 Redeliver was asked of a delivery being sent at this moment; read it again once the attempt has finished

Notices

DayZero can email each line's merchant on the partner's behalf. Every notice is off until the program switches it on:

json
"notices": {
  "due_soon": true,
  "past_due": true,
  "loan_paid": false,
  "statement_issued": true,
  "collections": true,
  "limit_changed": false,
  "reply_to": "ops@lender.example"
}
Notice Sent when Says
due_soon credit.loan.due_soon fires The transaction, its due date, and the amount due
past_due credit.loan.past_due fires The transaction, the date it was due, what is outstanding, and that interest now accrues daily at the loan's rate
loan_paid credit.loan.paid fires (a payment, or an adjustment, cleared the loan) The transaction and the date it was paid in full
statement_issued credit.statement.issued fires The period, the statement balance and the due date; on a per-transaction program in the wording below, with the due date as "Due {date}" or "Due now". Not sent when email_statements is on: the statement email is that notice
limit_changed The line's credit limit is changed The old and new limit and the available credit. Changes in available credit alone are not sent
collections A line enters a collections stage, whatever its action (notify, suspend_cards or suspend_line) Days past due, the amount past due, and the oldest due date

A statement on a per-transaction program sets no deadline. Each purchase has its own due date and a statement can be due the day it is issued (see Statements), so the statement email and the statement_issued notice never say "Payment due". Both carry the FRD §8.1 draft wording, with the term and rate read from the statement: "Your {Month} statement is ready. Each purchase on it has its own due date — {term_days} days after it posted. Purchases past their due date accrue interest at {rate} APR until they are paid. Issuing this statement adds nothing to what you owe and sets no new deadline: it shows what is outstanding and when each amount was due." Beside the statement balance they show "Due {date}", the statement's due date written out, or "Due now" when the statement is due the day it is issued. The wording is a draft under the lender's legal review and may change. A cycle-draw program's statement email and notice keep their own wording: a cycle-draw statement is one draw with one due date.

Notices go to merchant_email on the line; a line without one gets none. The nightly sweep reads the event feed from where it last stopped for each program and sends one email per event of a switched-on kind, so each notice goes once. Switching event notices on applies to what happens from then on, not to events already on the feed. A reminder that is moot by the time the sweep runs (a due-soon notice for a loan already repaid) is not sent. Every notice email, and the statement email, is sent only after the request or sweep that recorded it has committed, so a call that fails and is repeated cannot email a merchant twice. The sweep counts a notice as sent when it is recorded and handed to the send; if the email then fails, the record is removed and the next night's sweep sends it (the sweep's errors no longer carries it, because the failure happens after the sweep's response; look for credit_notice_send_failed in DayZero's logs, or ask us). A collections stage sends its notice once per entry into the stage, whatever its action, so a ladder that suspends without a notify stage before it still tells the merchant; a send that failed is retried the next night, and a dry run sends nothing. A notice's message_id is filled in a moment after the sweep returns.

Every email, the statement email included, is the lender's, not DayZero's: the program's logo, accent colour, footer text, support address and website from statement_branding, sent from the lender's name (statement_branding.lender_name, else display_name), with replies to notices.reply_to, else statement_branding.support_email. DayZero is named once, in a "serviced by" line at the foot. Amounts and dates are written out in full. Until the borrower portal exists, each notice tells the merchant to sign in to the lender's account to see what it owes and download statements.

An adverse-action notice is a borrower notice too (kind adverse_action), sent from the compliance queue rather than switched on here: see Adverse-action notices.

GET /credit/lines/{id}/notices lists what was sent on a line, newest first: kind, recipient, subject, sent_at, the SES message_id, the event_id it reported (none for a collections notice), the figures it quoted in detail, and suppressed. Notices are not events: sending one adds nothing to the event feed.

A sandbox never emails a merchant. On a sandbox each notice is recorded exactly as it would have been sent, with suppressed: "sandbox" and no message_id, so a rehearsal can read what the merchant would have received, and nothing is sent; the sweep counts them as suppressed. See On a sandbox.

Rehearsing with a partner's data

A rehearsal book has the partner's shape and volumes without being a migration (that is the loan import). It goes onto a sandbox tenant through the same services the API uses: a line per merchant, each card transaction as a settlement (negative amounts as refunds), each repayment as a recorded payment in date order, then the month-ends closed and interest accrued through as_of. Until the partner's data can be read, the portfolio scenario stands in for it.

Pulling partner test data

scripts/admin/credit/pull_dynamodb.py reads the partner's DynamoDB tables itself, read-only, through a role the partner creates in its own account. The role is never named in the repository: set PARTNER_DYNAMODB_ROLE_ARN and PARTNER_DYNAMODB_EXTERNAL_ID in the environment, or name the SSM parameters that hold them with --role-arn-ssm and --external-id-ssm (or PARTNER_DYNAMODB_ROLE_ARN_SSM and PARTNER_DYNAMODB_EXTERNAL_ID_SSM). With neither it refuses to start. The region is --region, else PARTNER_DYNAMODB_REGION, else AWS_REGION. The partner's role allows exactly two actions on the named tables, and trusts DayZero's account only with the agreed external id (an sts:ExternalId condition on its trust policy):

json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["dynamodb:Scan", "dynamodb:DescribeTable"],
    "Resource": [
      "arn:aws:dynamodb:us-east-1:111122223333:table/tie-merchants",
      "arn:aws:dynamodb:us-east-1:111122223333:table/tie-transactions",
      "arn:aws:dynamodb:us-east-1:111122223333:table/tie-repayments"
    ]
  }]
}
bash
python scripts/admin/credit/pull_dynamodb.py --table tie-merchants --table tie-transactions \
  --table tie-repayments --out-dir ./tie_pull
python scripts/admin/credit/pull_dynamodb.py --table tie-transactions --since 2026-04-01 \
  --since-attribute postedAt --limit 5000 --out-dir ./tie_pull

Each table is scanned in parallel segments (--segments, default 4); --limit caps the items per table, and --since keeps items whose --since-attribute is on or after a date (--since-format iso, epoch_s or epoch_ms). The script can only describe and scan: it holds no other DynamoDB call. It writes one <table>.ndjson per table, an {"Item": {...}} per line exactly as an S3 export writes it, and manifest.json with each table's item count, the file's SHA-256 and when it was scanned.

Redaction is on by default. Emails, people's and businesses' names, phone numbers, street addresses, tax ids and bank or card numbers are replaced before anything is written (a redacted tax id or account number keeps its layout and ends with check digits that mark it as redacted), found by attribute name (email, ownerPhone, businessName, taxId, accountNumber and their spellings) and by sniffing every value, so an email in a partition key or a notes field is caught too. Replacements keep their format and are salted per run: an email stays email-shaped at redacted.example, a phone keeps its digit count with area code 555, a name becomes Merchant-<hash> or Person-<hash>. The same value maps to the same replacement within a pull, so a merchant id that is an email still joins its transactions to its payments. Ids, amounts, dates, MCCs and card last-fours are kept. The manifest records that redaction was on and the id of the run's salt, never the salt. --no-redact needs --reason, which is logged and written to the manifest.

Registering the pull with the app. The files land in a local directory the app cannot see, so --upload sends them on once the pull is written:

bash
export DAYZERO_API_URL=https://api.dayzero.example   # the API's address
export DAYZERO_API_TOKEN=dz_...                      # a `credit` token of the sandbox
python scripts/admin/credit/pull_dynamodb.py --table tie-merchants --table tie-transactions \
  --table tie-repayments --out-dir ./tie_pull --upload --business-id <sandbox business id> \
  --label "Tie, April" --role transactions=tie-transactions --role merchants=tie-merchants

Both variables come from the environment, never the command line, and the script says which is missing before it touches AWS. It posts the manifest and every file to POST /credit/sandbox/partner-pulls (multipart/form-data: manifest, one files part per file, optional label and roles). Sandbox businesses only: a live business is CREDIT_090, and an API token is refused on one before that.

The app trusts nothing in the manifest. It recomputes each file's SHA-256 and refuses a mismatch (CREDIT_681); it applies the same limits as an upload to each file (10 MB, 20,000 rows: CREDIT_670, CREDIT_671, CREDIT_672); and it reads every file for real emails and phone numbers whatever the manifest says. A pull whose manifest says redaction was off, or whose files hold such a value, is refused with CREDIT_673 unless allow_unredacted is true with a reason (CREDIT_674 without one), which is logged and audited; from the script that is --no-redact --reason ... --allow-unredacted. A pull kept that way is listed as not redacted. A manifest that is not valid, or that names files not sent (or the reverse), is CREDIT_680.

Each table gets a role: transactions, merchants or payments. Say which with --role transactions=<table> (repeat for each), or leave it to the app, which runs the same column matching as mapping: auto on each file and gives a table the one section whose required fields all resolve. A table that fits two sections, that fits the same section as another table, or that fits none keeps no role (a transactions file also carries a merchant id, so a table that fits both is the transactions). Roles the app could not work out are asked for in the console, or sent as roles on the export. Roles that do not fit the pull are CREDIT_682.

The files are stored in the app's document storage under their content hash (credit-partner-pulls/<business>/<sha256>) and the pull is kept for 30 days (expires_at). GET /credit/sandbox/partner-pulls lists them, newest first and paged like the other lists, each with its label, its tables (item count and role), redacted, scanned_at, created_at and expires_at; expired pulls are removed, with their files, when it is called. DELETE /credit/sandbox/partner-pulls/{id} removes one and its files now; a book already seeded from it is not touched. A pull that expired and was not yet purged is CREDIT_683 if you seed from it. Hard-deleting a business removes its pull records but not their stored files, as it does not remove any other stored credit document.

The pull itself needs the partner's read-only role in the environment you run it from (above): that is configuration, not something the console can do.

Seeding from the files

scripts/admin/credit/seed_from_partner_export.py turns the files (a pull, a DynamoDB scan output or S3 export with typed values, a JSON list, or a CSV) into the book. Field names come from a mapping file (--mapping); a mapped column that no row carries is listed before the plan, so a typo shows before anything is written. --mapping auto works the field names out from the files instead: a column's header is matched against the names each field usually carries, then against its values (dates, amounts, last-four digits), the unit of the money is read off the amounts, and each field is listed with the column it was read from and how sure the match is. A field no column answers to keeps its default name and is listed as not found. The script prints the plan and writes nothing until --apply; run twice it adds nothing. The sandbox tenant must be is_demo for dates to run forward.

In the console, Seed from partner export in the sandbox tools does the same from an upload: POST /credit/sandbox/partner-export (multipart/form-data, sandbox only) with a transactions file, optional merchants and payments files, the program_id, an optional mapping (the JSON a mapping file holds, or auto to have the field names worked out from the files; the response then carries mapping_source: "auto" and mapping_notes, one line per field) and as_of (default today). It is a dry run unless dry_run is false, and returns the plan as figures (plan: counts, the activity window, spend, refunds and repayments, a row per merchant, the rows left out and why) and as the script's text (plan_text). Applying returns counts: lines created and reused, settlements accepted and already there, payments recorded. The console accepts files up to 10 MB and 20,000 rows each, and applies up to 2,000 transactions and payments; a larger export is seeded with the script. The console sends auto when the mapping box is left empty and shows the notes under How the fields were read.

To seed from a registered pull instead, send its pull_id and no files (CREDIT_684 for both, or for neither). The files are read back from storage and go through exactly the same steps: the plan, the warnings, mapping including auto, the dry run, the apply. roles (JSON of role to table name, e.g. {"transactions": "tie-transactions"}) overrides the roles the pull recorded, and a pull whose transactions table is not set is CREDIT_685 without it. In the console, Seed from partner export has a toggle: Upload files as before, or Pulled data, which lists the pulls with their tables and item counts, when they were scanned and whether they are redacted (a pull kept un-redacted is marked Not redacted). Choosing one previews and applies as an upload does; a pull whose tables could not be told apart asks which is the card transactions, merchants and repayments first. Delete removes a pull after a confirmation.

Each file is staged and scanned for malware, then read in a worker thread, counted as it goes, and refused at the first cap it passes: 500 columns (a JSON file may name 500 different fields in all), 10,000 characters in one cell, and 2,000,000 values. A file is read only to its first row past 20,000, so the refusal (CREDIT_671) comes before the rest is looked at. Anything malformed is CREDIT_672 with the file and, where it is known, the row: text that is not CSV or JSON, a row that is not an object, a typed attribute with the wrong shape ({"M": "x"}, an N that is not a number), an amount no ledger can hold (9e999990), a date outside 1970 to 2099, a whole number past what the record holds, or a value the services that seed it would refuse (an id over 128 characters, an object where text goes, a negative credit limit, an email over 255 characters, an MCC over eight characters). A payment the engine refuses at apply is still reported and skipped, as before. The seeder script reads local files without the console's caps.

Every value in the files that looks like a real person's details comes back in warnings (kind, file, row and attribute, masked): an email or a phone number, a tax id (an SSN or EIN), a card number, an IBAN or bank account number, or a street address. They are found the way the pull's redaction finds them, by the attribute's name first and by the value's shape where the name says nothing, so a nine-digit transaction id is not an SSN. What the redaction wrote always passes: a redacted email is at redacted.example, a phone has area code 555, an address reads Redacted-<hash> St, and a redacted tax id, card or account number ends with check digits that mark it. While there are any warnings, applying is refused unless allow_unredacted is true with a reason; the reason is logged and written to the audit log. A preview (dry_run) writes nothing and needs no reason, so allow_unredacted without one is only refused (CREDIT_674) when applying; the console sends the choice and the reason only on the apply, and clears them whenever its preview is cleared.

Code Meaning
CREDIT_090 The business is not a sandbox
CREDIT_670 A file is larger than the console accepts
CREDIT_671 A file has more rows than the console accepts
CREDIT_672 A file, the mapping, or a value in a file could not be read, or a file is past a cap (the message names the file and row)
CREDIT_673 Un-redacted emails, phone numbers, tax ids, card or account numbers, or addresses; send allow_unredacted with a reason to apply anyway
CREDIT_674 allow_unredacted without a reason
CREDIT_675 Nothing in the transactions file can be seeded; check the mapping against the column names
CREDIT_676 Too large to apply from the console; use the script
CREDIT_680 A pull's manifest is not valid, or does not match the files sent with it
CREDIT_681 A pulled file's SHA-256 is not the one its manifest records
CREDIT_682 roles is not role-to-table JSON, names an unknown role or a table the pull does not have, or gives one table two roles
CREDIT_683 The pull expired and its files were removed; pull and upload again
CREDIT_684 Send files or a pull_id on a partner export, not both and not neither
CREDIT_685 The pull has no transactions table recorded; send roles
CREDIT_686 A merchant in the export is already a line on another program; a line's external id is unique across the business

Sandbox

A sandbox is a business marked as one (is_demo), where the clock can run forward and nothing reaches the card issuer. A live operator business gets its own with one call; the console shows a Sandbox badge on Partner Credit and on each line of a sandbox.

Provisioning the paired sandbox

POST /credit/sandbox/provision on the live business creates " (Sandbox)" and pairs it to the live business. It is an operator action: a signed-in person in the console (or DayZero staff on your behalf), never an API token. In the console it is Create sandbox in the Partner Credit header, which offers two choices: create the sandbox alone ("mint_token": false), or create it and mint its API token, which needs approved API access. When the token is refused for what minting needs, nothing is created and the console offers to create the sandbox without one. Once the pair exists, Mint sandbox token beside the Live / Sandbox switch mints another. A token is shown once, in a dialog that closes only on I have copied it.

The sandbox gets:

  • the live business's firm links and its members with the same roles (people, firm staff assignments, client access, firm teams). A hidden support membership (DayZero staff added to investigate) is never copied;
  • a copy of every credit program's configuration, issuing from the mock issuer (issuing_provider: "mock"). Every field is copied except the issuer's spend program (ramp_spend_program_id) and the two GL ledgers (gl_funding_ledger_id, gl_cash_ledger_id), which belong to the live business: the sandbox posts to its own system ledgers; and the alert destinations (alert_emails, alert_slack_channel), so rehearsal alerts never page the live operations inbox. Set sandbox-specific ones on the copy if you want them; a sync keeps them. A copy keeps the live slug, or the slug with -2, -3, ... added when the sandbox already has another program with it;
  • each program's active scorecard versions, copied as the active version of a matching scorecard on the sandbox program (origin sandbox_copy, with the live approval record);
  • no lines, loans, cards or events. It is never billed: a sandbox passes every billing gate.

The call mints a credit API token limited to the sandbox and returns it once, as token (with token_id and expires_at): DayZero keeps only its hash. The token belongs to the person who made the call, a member of the business, and is listed and revocable on their token screen (Settings › Developers) like any other. Minting holds the call to what creating any token needs (approved API access, a two-factor check in the session, the token-creation rate limit); send "mint_token": false to provision without one. token_expires_in_days sets its lifetime (1 to 365, default 365). The token reaches the sandbox only: on the live business it is 403 AUTH_070.

DayZero staff may provision the pair on your behalf, but staff reach your business by elevation rather than as members, and a token minted for them would carry a DayZero superuser's full permissions under a user you can neither see nor revoke. So a call made by staff acting by elevation creates no membership for them on the sandbox and mints no token: the response says so in token_note, and asking for one ("mint_token": true) is 403 CREDIT_584. A member of the business then mints the sandbox token: this call with "mint_token": true, or the normal token screen on the sandbox.

Calling it again returns the same sandbox with created: false and mints no token unless you send "mint_token": true. The response lists each live program beside its sandbox copy and their scorecards. Calling it on a sandbox is 400 CREDIT_580.

The pairing. A live business has at most one paired sandbox. GET /credit/environment names the other half of the pair on each side (sandbox_business_id on live, live_business_id on the sandbox), and the console's Live / Sandbox switch moves between them.

Keeping it in step. POST /credit/sandbox/sync-config on the live business copies every program's configuration onto its sandbox copy again (a program added on live since is copied for the first time), and copies any scorecard version the live business has activated since, making it active on the sandbox. A version an operator activated on the sandbox itself stays active until live activates a new one. It reports per program action (created, updated, unchanged), the changed_fields, and each scorecard's action (created, version_activated, updated, unchanged). It never touches lines, loans or cards. Without a paired sandbox it is 400 CREDIT_581.

A sync also:

  • makes the sandbox's access mirror the live business: memberships (with their roles), firm links, client access, firm staff assignments and firm teams are added, removed and updated to match, so someone removed from the live business loses the sandbox too, and someone given access to the sandbox alone loses it (give them access on the live business instead). Hidden support memberships on the sandbox with no counterpart on the live business are removed. access reports members_added, members_removed, members_updated, hidden_members_removed, firm_links_added, firm_links_removed, firm_links_updated, and client_access_added, client_access_removed and client_access_updated (client access, staff assignments and firm teams together);
  • clears alert destinations a sandbox copy still shares with its live program (a copy made before provisioning stopped copying them), so rehearsal alerts stop reaching the live inbox; each program's alert_destinations_cleared names the fields. Destinations that differ from the live program's were set on the sandbox deliberately and are kept;
  • lists, as superuser_tokens, every live token limited to the sandbox that is owned by a DayZero superuser who is not a member of the live business (a token staff provisioning minted before this rule). They are listed, not revoked: superuser_tokens_note says how staff revoke each (POST /admin/tokens/{token_id}/revoke), and a member mints the replacement.

A copied change is a rehearsal of the live one, so it behaves as the same PATCH would on the sandbox program. A change to a pricing rule (interest_accrual_start, late_terms, early_payoff_terms, interest_cap_pct_of_principal_bps) reaches the sandbox's loans originated afterwards only, and when the copied compliance policy is on it is disclosed to the sandbox's lines that may borrow, as a terms_change disclosure: checked against the sandbox's approved templates before anything is copied (CREDIT_602 names the template to approve in the sandbox), generated and recorded, never emailed. A changed lending_model is not copied onto a sandbox program that has lines: the whole call is 400 CREDIT_565, and nothing is copied. Reset the sandbox (POST /credit/sandbox/reset, which deletes its lines and keeps its programs) and sync again.

Staff can still mark any one business a sandbox by hand (PATCH /admin/businesses/{business_id}/sandbox with { "is_demo": true }), which changes neither billing nor the firm's other businesses. Such a sandbox has no live pair.

On a sandbox

What changes on a sandbox:

  • Money dated in the future is accepted, so the clock can run forward (a live business refuses it with 400).
  • The clock moves only when you move it. The nightly 02:15 UTC sweep leaves a sandbox alone (it is the same is_demo flag, so any demo business is left alone too): nothing accrues, closes or is collected overnight, and a book staged for a walk keeps the dates it was staged to. The book moves when someone calls the sweep endpoints or the scenario and clock calls below on it.
  • The four sweep endpoints are open to your own API token, and so are completing an underwriting assessment by hand and running one at once (POST /credit/underwriting/assessments/{id}/completion, POST /credit/underwriting/assessments/{id}/run).
  • The scenario, clock and reset calls below work. On a live business every one of them returns 400 with code: "CREDIT_090"; there is no override, for staff either.
  • A program with issuing_provider: "mock" issues cards from DayZero's in-memory issuer: funds and cards are created and spent in memory, nothing reaches Ramp, and the state is cleared by a reset. Check GET /credit/environment for each program's issuing_provider_mode.
  • Nothing is emailed to a merchant, by any path: borrower notices (adverse-action notices included), collections notices and statement emails, from the nightly sweep, a clock move, or a send in the console. Each is recorded exactly as it would have been, so a rehearsal can inspect it: a notice is listed with suppressed: "sandbox", a statement email is recorded on credit.statement.emailed with suppressed: "sandbox", and the sweep counts both as suppressed. One deliberate exception, so the email itself can be rehearsed: an address on DayZero's credit_sandbox_email_allowlist setting (exact addresses, or @domain for every address there; empty unless you ask us) is sent to, with [Sandbox] before its subject.
  • A program alert routed to the program's own Slack channel or inboxes (alert_slack_channel, alert_emails) says [Sandbox] at the start of its title and subject, so a rehearsal routed somewhere on purpose is never mistaken for the live program.

Stage the worked example. POST /credit/sandbox/scenarios/appendix-b with { "program_id", "external_line_id"?, "as_of"? } opens a line and books the FRD Appendix B book through the same services the partner-export seeder uses: five March settlements, March's close, a $30,000 payment on 15 April, and every close through as_of (default 2026-05-01, never before the payment). Each month closes on the 1st of the next, in order, before anything dated later is applied, as the nightly run would close it on a live book, so the statements carry the issue and due dates the FRD gives: March issued 1 April and due 4 April, April issued and due 1 May. It returns credit_line_id, the counts, and the statements, newest first: April's amount_due_cents is 1558197. The program must be per-transaction (400 otherwise; 404 for an unknown program). Sending the same external_line_id again adds nothing; a new one stages a second line. Each run is recorded as a walk, with each expectation and what came out; the live business's launch checklist reads the last one.

Generate a whole book. POST /credit/sandbox/scenarios/portfolio-50 with { "seed"?, "merchants"?, "months"?, "program_id"?, "as_of"? } (defaults: seed 50, 50 merchants, 6 months, today) generates a book through the real services as the clock walks forward: lines opened at the start of the history, settlements and refunds ingested, and on each month's first day the accrual, the close and the collections ladder run as the nightly sweep would. Most merchants pay each statement on its due date against a payoff quote; some pay late or in part; one payment is returned and paid again; one transaction is disputed and left open; one loan is written off; and lines sit at every stage of the collections ladder, so every screen and report has data. Without program_id it creates (or reuses) a per-transaction program named for the seed with a three-stage ladder (Reminder at 1 day past due, Cards suspended at 30, Line suspended at 60); a named program must be per-transaction (CREDIT_525) and its own ladder is used. The same seed, size and as_of always produce the same book: the result carries loan_tape_digest, the loan tape's SHA-256 without its generated ids.

Cards. The program the scenario creates (or the seed's own, if an earlier attempt left it empty) issues on the mock issuer: issuing_enabled with issuing_provider: "mock". Every line gets one DayZero-managed card, issued through the same card service as POST /credit/cards, and each settlement is attributed to a card of its line by card_last_four. Every sixth merchant from the second (the second, eighth, fourteenth, ...) also has a card the partner registered, as POST /credit/cards/register records one, and a third of that line's settlements clear on it, so the book shows both managers. Collections suspends every card on a line at its stage, as it would on a live program: lines at Cards suspended or Line suspended end with their cards suspended (suspended_via: collections), the rest with active cards. The result counts cards_issued and partner_cards_registered. A named program_id gets cards only when that program already has issuing_enabled and issuing_provider: "mock"; otherwise the settlements carry a planned last four and nothing is attributed. Card ids come from the mock issuer, keyed by business and line, so the same seed gives the same last fours on the same business. Cards do not touch the loans, so the digest does not depend on them.

Fifty merchants take minutes (about two thousand settlements and two hundred and sixty payments go through the engine), so the call queues a job and returns 202 with it. Poll GET /credit/sandbox/jobs/{id} until status is succeeded, with the book's figures and each line's profile and collections stage in result, or failed, with the code and message in error (a failed run writes nothing). Asking again while the seed's job is queued or running returns that job with created: false; a seed already on the program is CREDIT_677, so reset first. A job has an hour, the workflow's own limit: one still queued or running an hour after it started (or was queued, if it never started) reads stale: true on GET /credit/sandbox/jobs/{id}. Its worker has gone and it will not finish; nothing was written. Stop polling it. Asking for the same seed again then records that job as failed (with the reason in error) and queues a new one. Fewer than nine merchants leave some cases out, and the deepest delinquencies need three months or more.

Move the clock. POST /credit/sandbox/advance with { "to": "2026-07-15" } first closes each month that has ended since the book was last closed, one at a time on the 1st of the next month, so every statement is issued on its own close date and its due date follows from that; then it runs the daily sweep as of to and returns its summary. One call catches up every accrual day. Statements and notices are not emailed from a sandbox, by this call or any other.

Clear, void or dispute a card transaction. On the mock issuer nothing happens at a card by itself, so POST /credit/sandbox/card-transactions reports a transaction on a mock-issued card the way Ramp's webhook would and sends it through the same ingest, so "a purchase appears as a loan with no call from you" can be shown:

Field Meaning
card_id The card's id from POST /credit/cards. It must be on a program whose issuer is the mock issuer (400 CREDIT_710 otherwise); an unknown card is 404
kind cleared (the default): a purchase, or with a negative amount a refund, has settled. voided: a cleared transaction is now reported voided. disputed: a dispute is listed on one
amount_cents cleared only, and required for it: signed cents, positive for a purchase, negative for a refund
merchant_name, merchant_category_code, settled_on cleared only. settled_on defaults to today, and may be later on a sandbox
issuer_transaction_id The issuer's transaction id. Optional for cleared (one is generated and returned; the same id again is a duplicate, not a second loan). Required for voided and disputed, which name a transaction recorded earlier (400 CREDIT_711 when the book holds none)
refund_of_issuer_transaction_id cleared with a negative amount: the transaction it refunds

The response gives issuer_transaction_id, kind, credit_line_id, loan_id, dispute_id when a hold was opened, and result: loan_created, refund_recorded, duplicate, reversed (a void with nothing paid on the loan took it back at once), hold_opened (a void with money paid on the loan, or a dispute, put it on hold for an operator), unchanged (already recorded, or not eligible) or ignored (the ingest did not take it), with detail saying why for the last two. Nothing is kept in the mock issuer's memory: a void or dispute finds its transaction in the ledger. On a live business the call is 400 CREDIT_090. It is not deduplicated without issuer_transaction_id, so read the line's loans before sending one again.

Record a walk. POST /credit/sandbox/walks with { "program_id", "credit_line_id", "steps": [{ "name", "expected", "actual", "passed" }] } (1 to 500 steps) keeps the result of a walk run by the validator as the sandbox's latest walk, which the live business's launch checklist reads (sandbox.last_walk). The validator posts its steps here when it finishes. passed is true when a step matched, false when it did not, and null when it was not judged; a null step is not a pass. The response gives the record's id, passed (true only when every step was judged and matched), steps_failed, steps_unjudged, and statements: each statement's period start, issued_on, due_date and amount due, read from the line, not from what was sent. An unknown program, or a line that is not on it, is 404, and a live business is 400 CREDIT_090. Each call adds a record; staging Appendix B records one too, and the newest is the one read.

Start over. POST /credit/sandbox/reset with { "confirm": "RESET" } deletes every credit line on the business and everything under it (events, ledger, payments and allocations, loans and accruals, settlements, cycles, cards, assessments and the scorecard history rows that name them, interchange rows, limit-change requests), deletes the sandbox's walk records, and clears the mock issuer. Programs and scorecards stay. It returns the rows removed per table. Any other confirm is 400 with code: "CREDIT_091".

In the console, the Sandbox tools card on Partner Credit (on a sandbox only) stages Appendix B on a per-transaction program, generates the portfolio for a seed and reports it when the job finishes (or says it stopped, once the job is stale, and stops polling), opens Seed from partner export, runs the sweep to a date, and resets the book after a confirmation. The card and the Sandbox badge follow GET /credit/environment (sandbox), and switching Live / Sandbox drops every figure the console fetched for the other book.

Code Meaning
CREDIT_090 The business is not a sandbox
CREDIT_091 Reset was not confirmed with "RESET"
CREDIT_525 The portfolio scenario's program_id is not a per-transaction program
CREDIT_565 sync-config would change the lending_model of a sandbox program that has lines; reset the sandbox and sync again
CREDIT_580 Provisioning or sync-config called on a sandbox; call it on the live business
CREDIT_581 sync-config on a live business with no paired sandbox
CREDIT_582 The paired business is no longer marked as a sandbox; ask DayZero to mark it again
CREDIT_584 (403) mint_token: true from DayZero staff acting by elevation; a member of the business mints the sandbox token
CREDIT_677 The program already holds the portfolio for that seed
CREDIT_710 A sandbox card transaction names a card that is not on the mock issuer
CREDIT_711 A sandbox void or dispute names an issuer_transaction_id the book does not hold; record it as cleared first

Launching with existing loans

Loans that already exist on a previous system are loaded with their paid buckets and interest to date, keyed by the original issuer transaction id so attribution survives the migration. Accrual resumes the day after accrued_through. An imported loan joins DayZero's books on the import's as_of (default today, UTC): the first statement that carries it is the one whose period holds that date, with what it owed as imported as part of the previous balance, and no statement is issued for the months before (see Statements). What it was repaid before the import is part of that opening position, never a payment in a period. So an as_of inside a statement period the line has already closed is refused row by row, with the date to import from instead, and a payment naming an imported loan dated before its import is 400 (CREDIT_529). Loans imported before DayZero recorded the import date (the release of 2 October 2026) were given one afterwards: the run's as_of for a file applied through the steps below, otherwise the day the import ran, which is the default as_of. A loan a closed statement had already carried for an earlier period stays on that statement, dated inside its period, so the next statement does not bring it forward a second time; such statements are never restated, and DayZero's staff hold the list of them. as_of may not be in the future on a live business (400 CREDIT_510, for the program file and the line import alike; a sandbox may run ahead). The interest a loan brings to accrued_through is a fact from the previous system, kept as one accrual record marked as imported: no backdated payment, return, dispute hold or goodwill credit removes or re-prices it, and accrual resumes after it. A dispute, adjustment or write-off dated before the import date is 400 (CREDIT_652, with the earliest date allowed); a dispute the issuer dates earlier holds from the import date instead (see Disputes and voids). The whole export goes in as one file, across the program, in five steps: parse, map, dry run, apply, report. In the console that is Import loans on the program's row under Programs. Every call in this section is an operator action: an API token receives 403. Tell us and we run it.

1. Parse

POST /credit/programs/{id}/loans/import/parse takes the export as a multipart file: CSV (comma, semicolon, tab or pipe separated) or XLSX (the first sheet), with a header row, 10 MB and 20,000 rows at most. DayZero works out which column is which, header names first (case, spaces, underscores and camelCase do not matter, so Posted At, postedAt and posted_at are one name, and a longer header such as Original Loan Amount USD holds loan amount), then the values: dates and epoch timestamps, four-digit card endings, true and false, and whether amounts are dollars (written with decimals) or cents (whole numbers of a large size). Every row is normalised to the per-line import's shape and staged for seven days. Nothing touches the book.

The file is staged and scanned for malware, then read in a worker thread, counted as it goes, and refused (CREDIT_622, naming the row and column where it can) at the first cap it passes: 500 columns, 10,000 characters in one cell, 2,000,000 cells (rows times columns), more empty rows than a worksheet holds, and for XLSX a workbook that unpacks past 100 MB or at a ratio that marks a decompression bomb, checked before it is opened. A workbook is read at the width it declares when that is 500 columns or fewer; a sheet that declares more (or none) is read at its header's width, and a value in column 501 refuses it. Rows are counted as they arrive: the first past 20,000 refuses the file (CREDIT_621) without the rest being read. A cell that cannot be what its field is becomes a row problem naming the row, field and column, never a refusal of the file: an amount no ledger can hold (9e999990; amounts are kept as whole cents up to 2^63 - 1), a date outside 1970 to 2099 (1e30 as epoch seconds, 9999-12-31), or a whole number past what the record holds.

Three optional form fields steer it. mapping_name starts from a mapping saved on the program, or from the built-in brex. mapping is a JSON object of field to column, {"principal_cents": "Amount"}, applied by hand on top (null leaves a field unread); a column it names must be in the file. amounts_in (dollars or cents) overrides the inferred unit.

Response field Meaning
staging_id The batch. Every later step takes it
columns[] Each header with up to three sample values
fields[] Every field with its source_column, a confidence from 0 to 1 (1 is set by hand; under 0.5 deserves a look), the reason in words, and the column's samples
amounts_in, amounts_in_reason The unit every money column is read in, and why
line_key_field What groups rows into lines: external_line_id when it is mapped, else external_merchant_id
lines[] Per line key: rows, rows with no error, principal and outstanding as the loans would carry them, and the program's line with that key
unmatched_keys Keys with no line on the program yet
problems[], problem_count Row problems (row_number, field, message, severity), the first 200. An error row will be rejected; a warning row loads as described, such as a transaction id that repeats on the same line
available_mappings Mapping names saved on the program, then the built-in ones

The fields are the per-line import's (external_settlement_id, originated_on and principal_cents are required; disbursement_fee_cents, term_days, due_date, rate_bps, merchant_name, card_last_four, description, principal_paid_cents, fee_paid_cents, interest_paid_cents, credit_applied_cents, interest_accrued_cents, accrued_through, written_off, written_off_on and source_system are not) plus the line key, external_line_id or external_merchant_id. Dates read as ISO dates or timestamps, MM/DD/YYYY (day first when the column has a day over 12), written months (Mar 5, 2026), or epoch seconds or milliseconds. Amounts may carry $, thousands separators, or parentheses for a negative. Rates read as percent (18, 18%), a fraction (0.18) or basis points (1800, or any value in a column whose header says bps). A card number or mask keeps its last four digits.

2. Map

PUT /credit/import-batches/{staging_id}/mapping with { "mapping", "amounts_in", "save_as"? } reads the staged rows again under a corrected mapping; the file is not uploaded again. Send the whole mapping: a field left out is not read. save_as also saves it on the program under that name, for mapping_name on the next file. The built-in brex mapping is a starting point, a best guess at the headers of a Brex export: check every field against your file, correct what differs and save it under your own name. A built-in name cannot be saved over. A batch that was applied keeps the mapping it was applied with.

3. Dry run and 4. Apply

POST /credit/programs/{id}/loans/import with { "staging_id", "dry_run", "as_of"?, "expected_count"?, "expected_principal_cents"?, "expected_outstanding_cents"?, "create_missing_lines"?, "line_defaults"?, "allow_cross_line_duplicates"?, "mode"? }. dry_run is true unless you say otherwise. A file of up to 1,000 rows runs in the request and answers 201 with the result below; a larger one runs as a background job (see Large files). The rows are grouped by line key and sent through each line's import, a thousand at a time, all inside one savepoint. With dry_run, or when any control total does not match across the whole file, nothing is written on any line, not even the lines the run would open. Otherwise every line is written together. Webhooks for the new loans and the issuer spend-limit pushes go out only once the file is kept.

The control totals come from your book: expected_count (rows in the file), expected_principal_cents (original principal summed over the file), and expected_outstanding_cents (principal, fee and accrued interest, less what was paid or credited; written-off loans count as zero).

create_missing_lines: true opens a line for each key the program does not have, with line_defaults (credit_limit_cents, and optionally term_days and start_date), through the ordinary line opening, so the program's limit envelope and status apply. When external_line_id is the key and external_merchant_id is mapped too, the new line carries both. A line that cannot be opened rejects its rows, which fails a file sent with control totals. Without create_missing_lines, a key with no line rejects its rows the same way, as does a merchant id two lines on the program share, or an external_line_id that belongs to a line on another program.

Each row is also held to the rules a loan keeps for good once it is imported (see One line, as JSON for the full list). A row whose interest to date is above the program's interest ceiling on that loan is rejected: the ceiling is on total interest, paid or not. A row whose interest would start on or before the import's as_of (the day after its due date, after any grace days; on a program that accrues from origination, the day after it was made), that still owes principal and is not written off, must carry accrued_through even when its interest is zero, or the first night would charge the whole gap; it is rejected without it. A row whose original transaction id is already a loan on a different line of the program, most likely one imported before under another merchant key, is rejected on the dry run and the apply alike; send allow_cross_line_duplicates: true to load such rows anyway, and each is listed in warnings. A run on a program with no interest ceiling carries a no_interest_ceiling warning: the ceiling is copied onto each loan as it is imported, so these loans would stay uncapped for good. Set the ceiling before the apply, or confirm uncapped is meant.

Response field Meaning
lines[] Per line: imported, duplicates, rejected, principal and outstanding, created for a line the run opened, and error when the whole line did not load
totals, expected What loaded (new plus duplicates) against the control totals
mismatches Each control total that did not match, in words
applied true only on a real run that tied
lines_created Lines opened, or on a dry run the lines it would open
imported, duplicates, rejected Counts over the file
rejections[] Rejected rows with their row number in the file and the reason, the first 200. The report holds every one, after a dry run as after an apply
warnings[], warning_count What to read before applying, the run's own first (no_interest_ceiling), then rows loaded with a note (repeats_id_on_other_line, under allow_cross_line_duplicates): each with code, message, and for a row row_number, line_key and external_settlement_id. The first 200; warning_count counts them all, and the report holds them all
report_id The import report of this run
dry_run_token On a dry run: a digest of the batch, the mapping in force and this run's inputs (as_of resolved, the control totals, create_missing_lines, line_defaults, allow_cross_line_duplicates). Null on an apply

Run it twice: once as a dry run with the control totals, and when mismatches is empty, again with dry_run: false. A batch can be dry-run as often as you like, and run for real again after a run that did not tie; once applied, it does not run again, so its report stays the record of what was written. Uploading the same file again is safe: a row already imported is a duplicate.

Apply what you dry-ran. Send the dry run's dry_run_token with the apply, together with the same inputs. The apply then runs only if the token is the batch's last finished dry run's and the apply's inputs, under the mapping now in force, digest to it; otherwise it is 400 CREDIT_690 and nothing runs. That catches an apply whose control totals, as_of, create_missing_lines, line_defaults or allow_cross_line_duplicates were changed after the dry run, or whose batch was re-mapped since (a new mapping clears the token). as_of counts as resolved: a dry run that left it to default to today does not match an apply made on another day. The token is optional; an apply without one runs as before. The console always sends it.

Large files run as a job

A run takes about 20 milliseconds a row as a dry run and 25 as an apply, so a file of thousands of rows outlives a request. mode says where a run happens:

mode Where it runs Answer
auto (the default) In the request up to 1,000 rows; above that, as a background job 201 with the result, or 202 with the batch
sync In the request; refused over 2,000 rows (CREDIT_634) 201 with the result
async As a background job, whatever the size 202 with the batch

A job answers at once with the batch, status running, and its run: id, kind (dry_run or apply), mode, started_at and progress. Poll GET /credit/import-batches/{id} every two seconds while status is running. run.progress says how far it has got (rows_done of rows_total, lines_done of lines_total, and the phase: queued, loading, importing, checking, saving, then done), run.elapsed_seconds how long it has taken and run.heartbeat_at when the job last reported. When the run finishes, status is dry_run, applied or failed as for a run in the request, and result holds the body the request would have returned: per-line results, totals against the control totals, applied, rejections and report_id. run.inputs keeps the options the run was started with.

The job runs the same code as the request and is just as atomic: every line is imported inside one transaction, committed once at the end. A dry run commits only its report, and an apply writes every line together or none. Webhooks and issuer spend-limit pushes go out only once an apply is kept.

A job that stops before it finishes (the worker crashed, the run timed out, or it was cancelled) writes nothing: its transaction rolls back whole. The batch reads failed with the reason in run.error and no result; run it again. A job whose worker died without saying so stops reporting: after ten minutes with no heartbeat run.stale is true, and starting that batch again, or another file on the program, takes the run over (the stale batch, if it is another one, reads failed with the reason). Until then a second start is refused.

One run at a time per program, in the request or as a job: starting a batch whose run is in progress is refused with CREDIT_635, and starting another file on the program with CREDIT_636. A running batch cannot be re-mapped (CREDIT_635) until its run finishes or goes stale. While an apply runs it holds every line in the file, taken together when it starts and in the same order the nightly sweep takes them, so writes to those lines wait for it; run a large apply before the program goes live or at a quiet hour. It holds nothing else: payments, settlements and limit changes on the business's other lines, and their credit events, go through while it runs. The run's own events (credit.loan.originated for each loan, credit.line.opened for each line it opens) are appended together as the file is kept, in the order they happened, numbered straight after anything posted meanwhile, and all carry the moment the file was kept as occurred_at. A dry run appends no events and holds only the line it is rehearsing at the time.

In the console, Dry run and Apply start the run and show its progress, rows and lines done and the time taken, then the same results. Apply sends exactly the inputs the last dry run used, with its token, and its typed confirmation shows that dry run's figures; changing any input (or the mapping) clears the dry run, so it must run again. An input the console cannot read (a control total like USD 6000) is marked on the field and blocks the run instead of being left out. You can close the wizard while a job runs: reopening Import loans on the program offers Show progress on the running file, and once the newest file's job has finished, Show result opens its outcome (the report of an apply, the results of a dry run, ready to apply, or why the run stopped). A job that stopped reporting is no longer polled: the wizard says its worker stopped and Run again takes the batch over.

5. Report

GET /credit/import-batches/{staging_id}/report returns the report of the last run, as JSON, or as a document with format=csv or format=pdf: the file's name and SHA-256, the mapping and unit used, who staged it and who ran it and when, the control totals against what loaded, and per line the file's count, principal and outstanding against the book's, with every mismatch (control totals first, then each line that does not tie), every rejected row (all of them, up to the 20,000 a file can hold) and every warning. A dry run stores its report too, so the rejected rows can go back to the partner before anything is applied. The book figures are the line's loans carrying the file's transaction ids as the book stood at the end of the run, or on a dry run as it would have stood. After an apply they tie to the loan tape for the same loans until a payment or accrual moves them.

GET /credit/import-batches/{staging_id} returns a batch's status (parsed, running while a job runs, dry_run, applied, failed when a real run did not tie or a run stopped, expired) with its totals, its last run's summary (last_run), its current or last run (run, with progress) and, once that run has finished, its result. GET /credit/programs/{id}/import-batches lists a program's batches, newest first, each with its run but without the result.

A batch waits seven days. After that it is refused and the file must be uploaded again. Expired batches are deleted nightly for every business, and by the business's next parse, except one whose job is still reporting. An applied batch keeps its header and its report as the record of the migration; only its staged rows are deleted.

Rehearsing. Until the partner's file exists, scripts/admin/credit/make_sample_import.py --business-id <sandbox> --program-id <id> --out sample.csv writes one in the brex shape (--shape brex, the default) from a sandbox book, one row per loan, and prints the control totals to enter. Waivers, goodwill credits, dispute reversals and partial write-offs have no column in the import, so they are folded into the paid and credit columns and the file's outstanding ties to the book. Import it into a fresh program with create_missing_lines to walk every step.

Code Status Meaning
CREDIT_525 400 The program is not per-transaction; loan import applies to per-transaction programs only
CREDIT_620 400 The file is larger than 10 MB
CREDIT_621 400 The file has more than 20,000 rows
CREDIT_622 400 The file cannot be read as CSV or XLSX, has no header row, or has no rows under it
CREDIT_623 400 The mapping does not fit the file: not JSON, an unknown field, a column the file lacks, one column for two fields, or amounts_in not dollars or cents
CREDIT_624 400 No saved or built-in mapping has that mapping_name
CREDIT_625 404 No such import batch on this business
CREDIT_626 400 The batch expired; upload the file again
CREDIT_627 400 The batch was staged for another program
CREDIT_628 400 The batch was applied, so its mapping cannot change
CREDIT_629 400 create_missing_lines was sent without line_defaults
CREDIT_630 400 The mapping names no line key: map external_line_id or external_merchant_id
CREDIT_631 400 The batch has no report yet: dry-run or apply it first
CREDIT_632 400 save_as names a built-in mapping
CREDIT_633 400 The batch was applied; its report is the record of that run, so upload the file again to run it again
CREDIT_634 400 mode: sync on a file of more than 2,000 rows: send async or auto to run it as a job
CREDIT_635 400 A run of this batch is in progress: poll it until it finishes. Also when its job stopped reporting but its worker still holds the program
CREDIT_636 400 Another file is running on this program; one runs at a time
CREDIT_690 400 The apply's dry_run_token is not the batch's last dry run's, or its inputs or the mapping changed since that dry run: dry-run again and apply with the new token

One line, as JSON

POST /credit/lines/{id}/loans/import is the per-line import the wizard runs underneath, for one line and up to 1,000 loans as a JSON body. In the console it sits under Advanced on a line's Settings.

Run it twice. First with dry_run: true and the control totals from your book: expected_count, expected_principal_cents (original principal summed over the file), and expected_outstanding_cents (principal, fee and accrued interest, less what was paid or credited; written-off loans count as zero). The response reports every row and the totals the file loads as, and writes nothing. When mismatches is empty, send the same body without dry_run. The control totals are checked on the real run too, and a file that does not tie writes nothing: applied is false and mismatches says which total was off.

Response field Meaning
accepted, duplicates, rejected Row counts. A row already imported is a duplicate, so sending the file again is safe
results[] One entry per row: row (position in the request, from 1), external_settlement_id, status, and reason: why a rejected row was rejected, or, on a row accepted under allow_cross_line_duplicates, a note that its transaction id is already on another line
principal_cents, outstanding_cents Totals over every row that was not rejected
applied false on a dry run, or when a control total did not match
mismatches Each control total that was off, in words

A row that cannot be a real loan is rejected on its own and the rest of the file still loads: a malformed row, more paid than was owed in any bucket, a due date before the origination date, or interest_accrued_cents without accrued_through. The rules the loan will keep for good are applied too, from the program's terms as they are copied onto it: interest_accrued_cents above the program's interest ceiling on that loan is rejected, and so is a row with no accrued_through whose interest would start on or before as_of (the day after its due date, after any grace days, or the day after it was made on a program that accrues from origination) while it still owes principal and is not written off, even when its interest is zero. A row whose external_settlement_id is already a loan on a different line of the same program is rejected, since it is most likely a loan imported before under another merchant key, unless the body sets allow_cross_line_duplicates: true, in which case it loads and its result carries a note. So is every new row when as_of falls inside a statement period the line has already closed (an issued statement is never restated); a row already imported on this line is still a duplicate. Send interest_accrued_cents as the total interest accrued to date, paid or not, with the date it is good through. A row with written_off: true loads as a written-off loan with a zero balance.

Launch readiness

Whether a program can go live is computed, not kept in anyone's head: GET /credit/programs/{id}/readiness runs every launch check against the data DayZero already holds and says, for each, what it found and what resolves it. Nobody ticks a box; fix the thing a check reads and the check passes.

Call Who What it does
GET /credit/programs/{id}/readiness Any One program's checks, evaluated now. Nothing is stored
GET /credit/readiness Any Every program on the business, newest first, each evaluated now; paged with cursor (20 a page, 100 at most)
POST /credit/programs/{id}/readiness/evaluate Operator Evaluates and stores the result (source: manual, with its result_id)

Your token may read both lists (credit and credit:read), so your integration can show its own launch state. The report carries ready (no blocking check fails), counts (passed, warnings, failures, not_applicable, blocking_failures), evaluated_at, activation_override (below) and checks, each:

plaintext
{ "key": "identity.lender", "area": "identity", "status": "fail", "blocking": true,
  "summary": "Statements and emails would go out without: statement_branding.logo_data_uri.",
  "detail": null, "subjects": ["statement_branding.logo_data_uri"],
  "resolve": { "kind": "console", "label": "Complete the lender identity",
               "target": "/cfo?section=partner-credit&edit_program=<id>&field=pg-logo" } }

status is pass, warn (worth a look; never stops activation), fail, or not_applicable (the program does not use what the check reads). subjects names what the finding is about (line ids, missing fields, templates), twenty at most. resolve is null on a pass; otherwise kind is console (a screen in the DayZero console; target is its path), api (target is the call) or staff (ask DayZero; target says for what). A check that cannot run is a fail with the error in detail; it never breaks the report.

key Blocking Fails when Not applicable when
policy.late_terms yes a cycle-draw program has no late_terms. In the console it opens the program form on Grace days after due date per-transaction (loans accrue at the note rate from the due date)
policy.early_payoff yes a cycle-draw program has no early_payoff_terms. In the console it opens the program form on the two Early payoff selects (interest, then disbursement fee) per-transaction
policy.underwriting yes no underwriting_criteria and no active scorecard version —
policy.interest_ceiling yes interest_cap_pct_of_principal_bps is null. The ceiling is copied onto each loan when it originates or is imported, so a loan made before it is set stays uncapped for good: it is decided before launch, not confirmed after. A set ceiling passes and is stated in words ("Interest stops at 100% of a loan's principal."), so a wrong value is seen. In the console it opens the program form on the interest cap on the sandbox
policy.collections_ladder yes collections_policy is null (undecided: nothing would be done about a merchant who stops paying, and nobody chose that), or the stored ladder is one the sweep cannot run. {"stages": []} passes and says nothing will be suspended for delinquency; a ladder passes and lists its stages ("30 days: suspend cards (Cards off)") on the sandbox
identity.lender yes legal_lending_entity, or statement_branding's lender_name, logo (logo_data_uri, or a data or https logo_url), support_email or footer_text is missing —
merchants.reachable yes a line that is billed (every line that carries debt: not closed, or closed and still owing) has no merchant_email neither email_statements nor any notices kind is on
cards.lines_have_cards yes an active line has no active card, under either manager no line is active
cards.issuer_connected yes DayZero manages cards and issuing_enabled is off (the program form's Card issuing enabled switch), there is no healthy Ramp connection (connect it at Integrations → Ramp, /integrations/ramp, with Card issuing (credit programs) ticked), the Ramp connection lacks the card-issuing scopes (funds:read, funds:write, cards:write, embedded_cards:write, spend_programs:read, spend_programs:write; reconnect Ramp there with Card issuing (credit programs) ticked, card_issuing: true, after allowing them in the Ramp app), or ramp_spend_program_id is unset. The mock issuer counts as connected; on a live business it is a warn the partner manages the cards and no DayZero-managed card is live
cards.ramp_webhook yes DayZero manages cards on Ramp and the Ramp connection has no live webhook subscription: there is no connection, no subscription is recorded, its setup challenge was never verified, or its signing secret is not stored (Ramp's deliveries then fail their signature check). Without it a card spends and no loan follows until the nightly reconciliation. Reconnect Ramp at Integrations → Ramp to create the subscription again the partner manages the cards and no DayZero-managed card is live, or the cards come from the mock issuer
events.webhook_endpoint yes event_delivery_mode is webhook or both and no active endpoint subscribes to a credit.* event polling
events.webhook_delivery yes under 95% of the last 24 hours' credit deliveries succeeded at the first attempt (redeliveries are not counted) polling, or nothing was delivered in 24 hours
events.feed_polled yes polling or both, and no API token has read GET /credit/events in 24 hours (below) webhook
controls.mcc_allowlist yes allowed_mcc_codes is empty and was never decided. Cleared on purpose (mcc_allowlist_decided_at set) is a warn —
books.gl_ties yes the tie-out for the program does not tie gl_posting_enabled is off
books.interchange no never fails: interchange_source is none, or estimate with no interchange_bps, is a warn, since no interchange would be booked on the sandbox
sandbox.paired no the live business has no paired sandbox on the sandbox
sandbox.last_walk no the last Appendix B walk on the paired sandbox did not pass: a step did not match, or was not judged (a step not judged is not a pass). Never walked is a warn on the sandbox, or no sandbox yet
access.billing yes the business's API tokens are refused now (its billing, or the CFO Suite add-on every /credit call needs, which it asks the route gate about), or will be within 30 days (a trial ending; see Billing and token access) —
migration.import_reconciled yes the report of any applied loan import on the program has ties: false or mismatches: every applied file is judged, so a later corrections file that ties does not clear an earlier one that does not no import was applied
operations.nightly yes the last finished sweep run is not succeeded, or a ledger check failed in it (chain, balance, sequence or credit balance). None yet is a warn —
compliance.templates yes a template the compliance policy calls for (the disclosure for each state its open lines are in, or the generic one, and the adverse-action notice when on) is not approved compliance.enabled is off
compliance.jurisdiction yes an open line lacks merchant_legal_name, merchant_state or merchant_address compliance.enabled is off

The sandbox checks never block: rehearsing is advised, not required. Two things record a walk on the sandbox, and the newest is the one read. Each POST /credit/sandbox/scenarios/appendix-b records how the staged book came out, step by step: the five settlements, the payment, March's statement and (once April has closed) April's, and on the FRD's terms (20% APR, a 1% fee, the default allocation) their amounts, 4545000 and 1558197, and their issue and due dates (March issued 1 April and due 4 April; April issued and due 1 May). On other terms the amounts and dates are recorded and not judged, so that walk does not pass. The validator records its own walk with POST /credit/sandbox/walks (see Sandbox). A walk passes only when it has steps and every one was judged and matched, and the record keeps each statement's issue and due dates as the line holds them. A sandbox reset deletes the walk records with the book.

In the console, the Launch checklist card on Partner Credit lists each program: "Ready", or how many checks block and warn. Open it for the checks by area; every failing check links to the screen that fixes it, or shows the call or the request to make.

Going live

A program becomes active with PATCH /credit/programs/{id} and status: active. On a live business, moving a program into active from draft, paused or retired is refused with 400 CREDIT_660 while a blocking check fails. The message lists the failing keys and errors maps each to its summary. The patch is judged as it would leave the program, so a patch that sets the missing legal_lending_entity and status: active together passes. A sandbox is never gated, and a program already active is not judged again by later patches.

An operator can make it active anyway by adding force_activate with a reason:

plaintext
PATCH /credit/programs/{id}
{ "status": "active", "force_activate": { "reason": "Tie accepts the open items; the logo follows on Friday" } }

The program then carries activation_override: forced_by, forced_by_user_id, forced_at, reason and failing_checks, and the same goes into the audit log. In the console, saving a program as active when the checks refuse it opens a dialog asking for the reason. Forcing needs a person signed in to the console; force_activate without a reason is 400 CREDIT_662, and from an API token 403 CREDIT_661 (the program update is an operator action anyway; see Operator actions). Sent when nothing needed forcing, force_activate is ignored and nothing is recorded.

Creating a program directly as active works as it always has, without the checks: the console's new-program form starts as a draft so its activation goes through the gate, and the nightly evaluation below flags an active program that fails a blocking check.

Every night

The daily sweep's readiness step evaluates every program in use on a live business, active or holding a line that is not closed (lines open under a draft program, and a paused program still has merchants), and stores the result (source: sweep). When a check that passed in the previous stored result fails now, it raises one alert, in the app and on the program's alert_slack_channel and alert_emails, saying what failed and what resolves it: urgent for a blocking check, a warning otherwise. A check that keeps failing does not alert again, and the same check failing twice in a day alerts once. The first evaluation of a program alerts on nothing, since nothing passed before it. The step reports programs, not_ready, regressions and alerts, and the sweep response lists each program as readiness (program_id, ready, blocking_failures, regressions, result_id). evaluate_readiness: false skips it; it is skipped on a sandbox. The last 90 stored results per program are kept; older ones are deleted nightly.

Who reads the feed

events.feed_polled reads a fact nothing else records: who polls GET /credit/events, and from where. Each read made with an API token in the default ascending order notes the token's id and the after it sent, one record per token. A read from a signed-in console session, or a newest-first read (order=desc), records nothing. Noting it never fails the read.

Code Status Meaning
CREDIT_660 400 The program cannot be made active while a blocking launch check fails; the message names the checks
CREDIT_661 403 force_activate from an API token: only an operator in the console can force activation
CREDIT_662 400 force_activate without a reason

Ledger

GET /credit/lines/{id}/ledger is the append-only register every balance is derived from, one page at a time. GET /credit/lines/{id}/ledger/verify recomputes the hash chain, the running balance, and the sequence, and is what settles a balance dispute against the posted accounting rather than a screen. DayZero runs it nightly for every line with a register, whatever its status, with a check that the line's credit balance equals the sum of its credit-balance entries. For every line at once, as a dated report for auditors, see the ledger proof.

What runs on DayZero's schedule

Collections follows the program's collections_policy ladder nightly, for every line that carries debt: a suspended line (its release depends on it) and a closed line until it is paid off, as well as active ones. A stage is entered when a line's oldest past-due loan not on hold has been due for the stage's number of days, and a stage may notify, suspend_cards or suspend_line. Entering any stage emails the merchant the collections notice when the program has notices.collections on, once per entry into the stage: a stage that suspends the cards or the line sends it as a notify stage does. With it off, a notify stage's outcome says the notice was not sent, and a suspending stage sends nothing and records only its suspension. A stage's action is applied when the line enters the stage, or moves to a deeper one (a higher days_past_due); a move back to a shallower suspending stage, because the merchant paid the oldest loan, applies nothing new. A suspension the issuer refuses never took effect, so it is retried each night until it holds, and a card issued while the line is in the stage is suspended the next night (the stage's credit.collections.stage_changed event records each refusal, and credit.card.suspended each suspension that took effect). What a person reverses while the line stays in the stage stands: a line an operator reinstates, and a card an operator releases, are left as they set them until the line moves to a deeper stage, or catches up and later falls behind again. The line's outcome says so each night it happens: "line left active as an operator set it during stage '…' — suspended again only at a deeper stage" with line_left_active: true, or "N cards left active as an operator released them during stage '…'" with their ids under cards_left_active; the collections step counts such lines as lines_left_as_set, with a note, and not in lines_moved. suspend_line suspends only an active line: a closed line stays closed and a pending one stays pending, and a line an operator had already suspended stays the operator's. When the merchant catches up and the line leaves a suspending stage, the cards that stage suspended are released and the line reinstated if collections suspended it (suspended_via: collections); a closed line keeps its cards suspended, since it never spends again. A line an operator suspended (suspended_via: operator, whether before the stage or during it), or one whose suspension the record cannot place (null), is never reinstated by collections: the line's outcome carries "line left suspended: suspended by an operator — reinstate by hand" (or "not known to be collections'"), with line_left_suspended set to its suspended_via (or unknown), the same words go on its credit.collections.stage_changed event, and the collections step counts it as lines_left_suspended, with a note. Reinstate it with PATCH /credit/lines/{id} (status: "active" and a reason) when you are ready. Only a card collections itself suspended is released: each card records who suspended it (suspended_via is collections, operator or borrower_portal), so a card an operator suspended, or one the merchant froze from the portal, stays suspended whenever it happened, even during the stage. Cards collections suspended before it recorded itself were stamped collections by a one-off backfill wherever the record proves it: the line was in a suspending stage when the card was suspended, or a credit.collections.stage_changed event lists the card as suspended in that run. A suspended card with no suspended_via that the record cannot place stays as it is and is never released automatically. When a line leaves a suspending stage with such a card, suspended during that stage, the sweep says so instead of staying silent: the line's outcome and its credit.collections.stage_changed event carry "N cards left suspended: not known to be collections' — release by hand" (the card ids are under cards_left_suspended in the outcome), and the collections step counts them as cards_left_suspended, with a note. Release them with DELETE /credit/cards/{id}/suspension. The ladder is checked when the program is created or changed, and one the sweep could not run (an unknown action, a threshold that is not a whole number of days from 1 to 365, two stages on the same day or with the same name, a key that is not a ladder or stage setting) is refused with 400 CREDIT_720. A stage whose action is not one of the three can therefore only be on a ladder stored before that check: it is reported in the sweep's outcome as skipped and does nothing, and the ladder fails the policy.collections_ladder launch check. Every move into, out of, or between stages emits credit.collections.stage_changed, and a stage that suspends or reinstates the line emits credit.line.suspended or credit.line.reinstated with actor collections. A stage that only notifys emits the stage event and sends the notice (when notices.collections is on), nothing more: it is what your own dunning hangs off.

Nightly at 02:15 UTC, for every business with credit work (a line that carries debt or has a register, a pending adverse-action notice, or an active program) except a sandbox, in this order: bank-matched settlement (programs with bank_settlement_matching on), daily accrual and past-due flags, month-end close for any ended period (each statement is stored exactly as issued), collections, AutoPay due-today events, the issuer spend-limit push (or credit.line.limit_changed for partner-managed cards), deferred fee recognition, bank balance snapshots, interchange estimates, settlement reconciliation against the issuer (which also places void and dispute holds, re-reads older held transactions, below, and on a program with an MCC allow list counts the week's off-list settlements as off_allowlist, whoever manages the cards), statement emails and borrower notices (recorded and not sent on a sandbox: see On a sandbox), the compliance step (first the terms-change disclosures a program change queued; then pending adverse-action notices due within five days or overdue raise an alert, and nothing is sent; the step is skipped when neither has anything to do; alert_adverse_actions: false skips it, queued disclosures included), card sync, pending underwriting measurement, limit reviews for lines due in their program's cadence window, with the expiry of limit-change requests past their expires_at (the sweep response counts both under limit_reviews, the expired requests as expired; review_limits: false skips the step, expiry included), ledger verification (hash chain, running balance, and sequence, and each line's credit balance against the sum of its credit-balance entries), and launch readiness (the launch checks of each program in use, active or holding a line that is not closed, evaluated and stored, with one alert when a check that passed in the previous stored result fails now; skipped on a sandbox; evaluate_readiness: false skips it). Which lines each step works on depends on what the step is for, never on a line's status alone. Accrual and past-due flags, month-end close and statements, collections, AutoPay due events and the statement emails work on every line that carries debt: every line that is not closed, and a closed line while it still has an open loan, a statement period not yet closed or not yet paid, or a collections stage. The spend-limit push reaches every line with a card, and gives a line that cannot spend no headroom. Settlement reconciliation and its dispute look-back read every active or suspended card, and every card terminated within the longer of the two look-backs, whatever its line's status. Limit reviews are for lines that can spend (only active ones). The bank balance snapshots are taken for the business itself when it has a line that can spend, for the merchant business behind each line that can spend (merchant_business_id), and for the merchant business of each pending underwriting assessment (subject_business_id), each merchant on its own, so one that fails is recorded and the rest are still taken; with none of these the step is skipped. Ledger verification and deferred fee recognition read every line with a register. A step with nothing to work on is skipped with a note (for example "No line carries debt." on a business swept only for a pending notice), never failed.

A sandbox (a business marked is_demo, which covers every demo business) is not swept on the schedule: its book moves only when someone calls the sweep endpoints below or the sandbox scenario and clock calls on it, so a book staged for a walk keeps the dates it was staged to rather than being accrued and closed to today overnight.

POST /credit/sweeps/daily runs the same sequence on demand, and POST /credit/accruals/run runs accrual alone, for any date that has already happened. Both are operator-only on a live business and open to your token on a sandbox. Separately, every minute, webhook deliveries that are due are sent: retries whose time has come (see Retries), any delivery still pending ten seconds after it was written, and an attempt left sending past its time, which is counted as failed (see When a delivery is sent). Daily at 03:00 UTC every business operating a program in use (active, or holding a line that is not closed) is checked for API access that has lapsed or is about to (its billing, or the CFO Suite add-on), with one alert per business per day (see API tokens). Nightly at 03:30 UTC, for every business whether or not it has an active line, loan-import staging past its seven days is deleted (an applied file keeps its header and report, not its staged rows), and stored launch-readiness results beyond the last 90 per program. A business DayZero staff have archived is left out of the nightly sweep and the daily check until it is unarchived. On a live program these endpoints — with POST /credit/cycles/close and POST /credit/cycles/assess-late — refuse a date in the future with 400; on a sandbox tenant they run the clock forward, which is what the end-to-end walk relies on.

Each step runs on its own. One that fails is recorded with its error and the steps after it still run; the run's status is then partial (failed when every step that ran failed). A ledger that fails verification fails its step and raises its own urgent alert; so does a credit balance that is not the sum of its entries (the alert gives both figures). Every run — nightly, by hand, or a sandbox running its clock forward — is logged:

Call Who What it does
GET /credit/sweeps/runs Any Runs newest first, 14 to a page: as_of, started_at, finished_at, trigger (scheduled, manual, sandbox_advance), status, every step with its status (succeeded, failed, skipped), counts, duration_ms, and error or note, plus ledger_checks (lines verified; chain, balance, sequence and credit-balance failures), totals (loans accrued, interest, periods closed, notices sent, payments settled and returned), errors, and who started it. Filter with status
GET /credit/sweeps/runs/{id} Any One run
POST /credit/sweeps/runs/{id}/rerun-step Operator {"step": "close"} runs that step again for the run's date. Its outcome replaces the old one, the attempt is added to the step's reruns, and the run's status is recomputed, so a partial night reads succeeded once the failed step has been re-run cleanly. A step turned off for the run can be run this way too. A live business may not re-run a date that has not happened; a sandbox may

POST /credit/sweeps/daily returns the run's run_id, status, and steps beside the step results. In the console, the Operations card shows the last 14 runs with a dot per step; a run opens in a drawer with every step and a Re-run for each.

Code Status Meaning
CREDIT_308 404 No such sweep run on this business
CREDIT_309 400 The run has not finished; re-run a step once it has

On a per-transaction program a statement is never late in itself: its loans are, each against its own due date. POST /credit/cycles/assess-late there brings the past-due loans' daily interest up to date and does nothing else. It does not mark the statement late, add a late charge on top of the accrual, or emit credit.cycle.late; those apply to cycle-draw programs only. A statement that is due the day it issues (every loan on it already past due) adds no fee and no event for the same reason.

Settlement reconciliation looks back seven days for transactions. Ramp can list a dispute on a transaction, or cancel or resolve one, long after that, so a second pass lists each card's transactions again over 30 days by default (dispute_lookback_days, 1 to 120, on POST /credit/cards/reconcile-settlements) and reads every one that matches a settlement recorded before the seven-day window for disputes and voids: a dispute the issuer lists for the first time on day 12 opens a hold even though nothing was open here, a cancellation releases the hold, a void holds or reverses the loan. It only ever applies disputes and voids: nothing is ingested from the wider window, nothing else about the settlement changes, and running it again changes nothing (a dispute already recorded is matched on its id). A card whose line has no settlement from those older days is not asked. The issuer's list cannot be filtered to disputed transactions, so this is one more paginated list call per such card each night. The response counts dispute_lookback_candidates (issuer transactions examined beyond the main window) and dispute_lookback_rechecked (those in question on either side, whose hold was placed, changed or confirmed). Set it no longer than days to turn the pass off.

Portfolio and loan tape

GET /credit/portfolio (optionally program_id, as_of) sums the book: outstanding by component, past-due aging in the program's allowance buckets with the reserve each implies, month-to-date interest and fee income, originations, payments, write-offs, and the lifetime default rate. Loans on hold are counted on their own (loans_on_hold, on_hold_cents): inside total_outstanding_cents, outside past_due_outstanding_cents and the aging buckets, so the buckets plus on_hold_cents make up the total. GET /credit/portfolio/loan-tape?format=csv is one row per loan with every balance and date, including hold_status, hold_started_on, hold_resolved_on, hold_outcome and the reversed amounts (a held loan shows days_past_due 0); it ties to the portfolio figures exactly. Every row sums on its own: principal minus principal_paid, credit_applied, written_off, goodwill_credit and principal_reversed is principal_outstanding; disbursement_fee minus fee_paid, fee_waived and fee_reversed is fee_outstanding; interest_accrued minus interest_paid, interest_waived and interest_reversed is interest_outstanding; and the three outstanding columns add up to outstanding. The adjustment columns fee_waived, interest_waived and goodwill_credit follow credit_applied. The borrowing base and the tie-out in the finance pack are built on the same figures.

Interchange

Interchange the issuer shares on card spend is DayZero's to recognise (D-32). Two sources, per program: an estimate at interchange_bps on every cleared settlement (nightly), and the issuer's report:

plaintext
POST /credit/interchange/report
{ "rows": [ { "external_reference": "<issuer row id>", "external_settlement_id": "<issuer transaction id>", "interchange_cents": 20510, "recognized_on": "2026-04-30" } ] }

A row may be attributed by issuer transaction id, provider_card_id, or credit_line_id; with none of those, pass program_id and the row lands on the program unattributed. A reported row for a settlement already estimated supersedes the estimate and the operator's books move by the difference (Interchange Receivable / Interchange Income). Idempotent on external_reference: a row already recorded answers duplicate, also when a second report carrying it arrives while the first is still being recorded (it waits for the first). GET /credit/interchange returns month-to-date estimated and reported figures, year-to-date, lifetime, and a per-line split; the portfolio carries interchange_mtd_cents.

Finance pack

What the lender's finance team and its auditors read. Every figure is built from the loans, the ledger registers and the journal entries the engine already keeps; nothing here changes them. Each endpoint takes an optional program_id; without it the whole book on the business is in scope.

Journal

plaintext
GET /credit/finance/journal?period=2026-04&program_id=PROGRAM_ID&format=json

Every journal entry the program posted to the operator's books in the calendar month (period is YYYY-MM), one row per leg: entry_date, journal_entry_id, entry_number, memo, event (origination, accrual, accrual_reversal, fee_recognition, payment, payment_reversal, refund, write_off, interchange), reference (the issuer transaction id, partner payment id or interchange memo), credit_line_id, loan_id, account, account_number, debit_cents and credit_cents. totals_by_account sums each account, and control carries entry_count, line_count, total_debit_cents, total_credit_cents, balanced (debits equal credits) and unbalanced_entry_ids (always empty on a sound book). format=csv returns the rows as a spreadsheet, amounts in dollars, ending with a TOTAL row.

Only programs with gl_posting_enabled post entries. Each entry is selected by the program tag DayZero writes on it when it posts, so entries posted before the finance pack shipped (October 2026) carry no tag and are not in the export.

POST /credit/finance/journal/push with {"period": "2026-04", "program_id": "…", "destination": "quickbooks"} sends the same entries to the business's connected QuickBooks company, one QuickBooks journal entry each, and returns a result per entry: pushed (with the QuickBooks id in external_id), already_pushed, skipped (an entry touching an account with no linked QuickBooks account; reason names the accounts) or failed (QuickBooks refused it; reason says why). It is an operator action and safe to repeat: an entry is pushed once, a retry after a timeout cannot post it twice, and a later QuickBooks import does not bring a pushed entry back as a duplicate. Accounts are linked when the chart of accounts came from the QuickBooks import.

Borrowing base

plaintext
GET /credit/finance/borrowing-base?as_of=2026-05-10&program_id=PROGRAM_ID

The program's borrowing_base_policy decides eligibility:

plaintext
receivable (per open loan) = unpaid principal, + unpaid fee when include_fees
ineligible                 = more than max_days_past_due past its due date,
                             otherwise on hold (when exclude_on_hold)
pool                       = the receivables that remain
concentration limit        = pool × concentration_limit_bps ÷ 10,000
excess (per merchant)      = the merchant's share of the pool above the limit
eligible                   = pool − every merchant's excess
availability               = eligible × advance_rate_bps ÷ 10,000, rounded down

Accrued interest is never eligible. A merchant is the line's external_merchant_id (else its merchant_business_id, else the line); only the excess over the limit is ineligible, taken from the merchant's largest lines first. Written-off loans carry no receivable and are reported as written_off_cents for completeness. The response gives the totals, concentration per merchant, and one row per line with open loans; format=csv returns the line rows and a TOTAL row carrying the advance rate and availability.

total_outstanding_cents, principal_outstanding_cents, fee_outstanding_cents and interest_outstanding_cents are the portfolio's figures for the same scope and date, and total_outstanding_cents = gross_receivable_cents + excluded_fees_cents + excluded_interest_cents. A borrowing base is computed per program: when the lines span more than one, pass program_id (400 CREDIT_003 otherwise).

Tie-out

GET /credit/finance/tie-out?as_of=&program_id= states outstanding three ways and compares each pair (difference_cents, ties):

Figure Source
loan_tape_cents Every loan's balance, summed over the loan tape
ledger_cents Every line's register: the principal, fee and interest movements dated on or before as_of
gl_receivable_cents Notes Receivable plus Interest Receivable on the operator's books, through as_of

With program_id the GL figure counts only the entries that program posted (gl_scope: "program_entries"); without it, it is the accounts' whole balance (gl_scope: "account_balance"), so an entry posted to them by hand shows as a difference. When a program in scope does not post to the GL, gl_receivable_cents is null and the GL pairs carry ties: null. The loan tape is the book now, so run the tie-out for today to compare all three; for an earlier as_of the register and the GL describe that date and a loan-tape difference is activity dated after it. lines compares tape and register line by line.

Ledger proof

GET /credit/finance/ledger-proof?as_of=&program_id= verifies every line's register end to end (hash chain, running balance, sequence) and returns a dated report: lines and entries checked, the failures of each kind, and per line the three results, the newest entry's sequence number and hash (head_hash, what the register commits to), the balance from entries dated on or before as_of, and the first entry that failed. format=pdf returns the report as a document for auditors.

Refusals

Code When
CREDIT_001 period is not a calendar month written YYYY-MM
CREDIT_002 Journal push with no QuickBooks connection on the business
CREDIT_003 Borrowing base without program_id when the lines span several programs

Disclosures and adverse action

These figures are computed to the methods named; whether and where a disclosure or notice is required is for the lender's counsel. Generating, delivering and recording the documents themselves is Compliance.

Disclosure figures

GET /credit/lines/{id}/disclosure?principal_cents=1000000&term_days=30&transaction_date=2026-05-01 returns the figures for one prospective loan on the line's terms. term_days defaults to the line's term and must be one of the program's (400 CREDIT_004 otherwise); transaction_date defaults to today.

Field Meaning
amount_financed_cents The principal. The card spend is advanced in full; the fee is added to what is repaid
disbursement_fee_cents, interest_in_term_cents The fee, computed as origination computes it, and the interest owed if repaid on the due date
finance_charge_cents Fee plus interest within the term
total_of_payments_cents Amount financed plus finance charge
apr_bps, apr_percent The annual percentage rate, to the basis point and as a percent to two places
payment_schedule One payment, of the total, on the due date
prepayment decided, prepayment_penalty_bps, fee_rebate, interest_rebate, and a summary
method, day_count, days_in_year How the APR was computed
counsel_note The sentence at the top of this section

The APR is the actuarial method for a single advance repaid in a single payment, where the term is the one unit-period: APR = finance charge ÷ amount financed × (days in year ÷ term in days), with 365 days for actual/365 and 360 for actual/360 and 30/360. On a per-transaction program interest starts after the due date, so the finance charge is the fee alone. A $10,000.00 loan at a 1% fee on a 30-day term has a finance charge of $100.00, a total of payments of $10,100.00, and an APR of 100 ÷ 10,000 × 365 ÷ 30 = 12.17%. The same loan on a program that accrues from origination at 20% adds $164.38 of interest within the term (10,000 × 20% × 30 ÷ 365), for a finance charge of $264.38 and an APR of 32.17%. A cycle-draw line is charged a full term of interest at close on actual/365.

Prepayment on a per-transaction loan is fixed: no charge for repaying early, the fee is earned when the loan is made, and interest accrues by the day, so there is nothing unearned to rebate. The program's early_payoff_terms apply to cycle-draw lines only.

Adverse-action reasons

GET /credit/underwriting/assessments/{id}/adverse-action returns the assessment's adverse_action_reasons (see Underwriting), each with a sentence built from the figures, for example "Average daily cash (90d) was $40,000; the program requires at least $125,000.", plus status, decision (always null) and counsel_note. The list is empty while the assessment is pending or when nothing failed. Nothing is sent to anyone.

Compliance

Shipped dark. Everything in this section is inert until a program's compliance policy says enabled: true: without it a line opens, changes limit and changes status, and a program changes its terms, exactly as it always has, and nothing is written to the compliance records. DayZero builds and runs the machinery — the figures, the documents, delivery, acknowledgement and the record of each. Whether a disclosure or a notice is legally required in a given place, and its wording, is the lender's counsel's decision, recorded by approving a template in the console. Nothing here claims that a document meets any requirement.

The policy

json
"compliance": {
  "enabled": true,
  "disclosure": { "mode": "require_acknowledgement", "states": "auto" },
  "adverse_action": {
    "enabled": true,
    "deliver_within_days": 30,
    "creditor_name": "Revenue Roll Lending dba Tie Capital",
    "creditor_address": "1 Lender Way, Austin, TX 78701",
    "creditor_phone": "+1 512 555 0100",
    "include_delinquency_actions": false
  },
  "templates": { "disclosure_version": null, "adverse_action_version": null }
}
Field Meaning
enabled The master switch. Off (the default): nothing below applies
disclosure.mode off (default): no disclosures. generate: one is generated and recorded on each trigger. require_acknowledgement: as generate, and a new line waits pending and a limit increase is held until the merchant acknowledges
disclosure.states auto (default): every line, from the template approved for its state, else the generic one. A list of state codes: only lines in those states; any other line's disclosure is recorded as not_required, with the reason
adverse_action.enabled Record adverse actions and queue their notices
adverse_action.deliver_within_days due_on is the action's date plus this, 1 to 120. Default 30
adverse_action.creditor_name, creditor_address, creditor_phone Who the notice comes from. creditor_name is required when adverse actions are on
adverse_action.include_delinquency_actions Also record suspensions the collections ladder makes. Off: only a person's actions, and declines
templates.disclosure_version, templates.adverse_action_version Pin a version (v1); null uses the newest approved one

It is set with the program (POST /credit/programs, PATCH /credit/programs/{id}, operator actions) and validated there: an unknown key, a state that is not a US code, an empty states list or adverse actions without a creditor_name is 400. A PATCH replaces the whole policy; null removes it.

Before switching a policy on, give every open line its jurisdiction (see Open a credit line). GET /credit/compliance/jurisdiction-gaps lists the lines that are not closed and lack merchant_legal_name, merchant_state or merchant_address, oldest first, with missing naming the fields: with program_id, that program's lines whether or not its policy is on yet; without, the lines on every program whose policy is on.

Templates and approval

Templates ship with DayZero, one file per kind, jurisdiction and version: disclosure/generic/v1, disclosure/CA/v1, disclosure/NY/v1 and adverse_action/generic/v1 today. Every one starts as a draft. A draft renders with "DRAFT — not approved for delivery" across every page and is never delivered to a merchant.

Call What it does
GET /credit/compliance/templates Every template version, with status (draft, approved, retired), reviewed_by, reviewed_on, signoff_document, approved_by_user_id, approved_at, applies_if, file_present, changed_since_approval and deliverable
GET /credit/compliance/templates/{id}/preview The template as a PDF (format=html for the page), from a line's figures and names (credit_line_id) or a sample line's. Watermarked unless approved. Nothing is stored or sent
POST /credit/compliance/templates/{id}/approve { "reviewed_by", "reviewed_on", "signoff_document", "applies_if"? }. Records counsel's review and the operator who approved it, and when. Only a draft can be approved
POST /credit/compliance/templates/{id}/retire Withdraws a version for good. Documents already issued from it are unchanged

All four are operator actions. An approval covers everything the document is made from, exactly as approved: one SHA-256 is recorded over the template's own file and every template it extends, includes or imports (every template is built on the shared layout, which carries the header, the footer and the DRAFT watermark), so a change to the template or to the layout afterwards makes it no longer deliverable (changed_since_approval: true; a document that needs it is refused with CREDIT_603). An edit to one template leaves the others approved; an edit to the layout un-approves all of them. New wording is a new version, approved in its turn. applies_if optionally limits a template to lines whose amount financed is at most max_amount_financed_cents.

Approvals recorded before the hash covered the layout were made against the template's file alone, so they now read changed_since_approval: true: approve the template again. Every template shipped as a draft and compliance is off unless a program switches it on, so none was delivering.

For a line in a given state, the template used is the newest approved version for that state, else the newest approved generic version (or the pinned version), whose applies_if admits the amount financed. When there is none, whatever needed the document is refused with CREDIT_602, and the message names the template to approve, for example "disclosure/CA or disclosure/generic". Nothing is skipped silently.

Disclosures

A disclosure is generated, stored and recorded when, on a program whose disclosure.mode is not off:

Trigger kind trigger
A line opens offer line_opened
A line's limit is raised, by any path (PATCH, an approved limit-change request, an applied limit review) limit_change limit_increase
The program's terms change: a PATCH /credit/programs/{id} that changes lending_model, interest_accrual_start, late_terms, early_payoff_terms or interest_cap_pct_of_principal_bps, or a sync-config that copies such a change onto a sandbox program terms_change terms_change
An operator asks: POST /credit/lines/{id}/disclosures { "kind": "offer" | "limit_change" } as asked manual

A decrease generates no disclosure.

A change of terms. What a merchant is charged on the loans still to come is set in two places. A line's rate, fee and term are copied onto it when it opens, from the request or from the program's default_rate_bps, default_disbursement_fee_bps and allowed_term_days. PATCH /credit/lines/{id} cannot change them (400), and changing the program's defaults changes the lines opened afterwards only, so neither is a change of terms for a line that exists and neither is disclosed. The program's pricing rules are different: lending_model, interest_accrual_start, late_terms (grace, day count and cap for interest after the due date), early_payoff_terms and interest_cap_pct_of_principal_bps are copied onto each loan when it originates, so changing one changes the terms every line's next loans are made on. Sending the value a field already has is not a change. (lending_model cannot change once the program has a line, CREDIT_564, so in practice it is never disclosed.)

Such a change is disclosed to every line on the program that may borrow on the new terms: active, pending (it opens on them) or suspended (it spends on them once reinstated); a closed line will not borrow again and gets none. Each line gets one terms_change disclosure on its current limit, with figures.changed_terms naming the fields that changed, figures.applies_from the day they changed, and the first assumption saying the figures are on the terms as changed, that the new terms apply to purchases that post from that day on, and that each purchase posted before keeps the terms it was made under until it is repaid. It supersedes the line's previous unacknowledged disclosure. The first 25 are generated in the PATCH request itself, pending lines first (each is waiting on its disclosure to open); the rest are recorded queued at once and generated by the nightly sweep's compliance step, up to 500 a night per business, which counts them as disclosures_issued, disclosures_not_required, disclosures_failed and disclosures_still_queued. A queued disclosure for a line that closes first, or on a program whose disclosures are switched off first, is recorded not_required with the reason; one a newer disclosure on the line overtakes is superseded; one that cannot be generated (no approved template) stays queued, fails the step with its error, and is tried again the next night. A line outside disclosure.states is recorded not_required at once. The change itself is refused with CREDIT_602 before anything is written when no approved template covers a state among the lines it reaches.

Nothing is held by a change of terms: the program's terms are the program's, and they apply to every line's next loan as soon as they change. Loans already originated keep everything they were made under: their rate, fee, term and due date, and the program's rules as they stood (pricing_terms on the loan: accrual start, grace, day count, cap), for the days already accrued and the days still to come. The portal's disclosure page says so too. A queued disclosure generated on a later night still dates the change to the day it was made.

The figures are the disclosure figures on the full limit, drawn in one transaction today and repaid on its due date: amount financed, finance charge, estimated APR, total repayment, term, the payment method and the prepayment terms, with the assumptions written out. The document is rendered from the approved template, and its PDF stored once.

GET /credit/lines/{id}/disclosures lists a line's disclosures newest first:

Field Meaning
status generated, pending_acknowledgement, acknowledged, superseded, overridden (an operator activated the line without the merchant's acknowledgement), not_required (the line is outside disclosure.states; no document), or queued (a terms-change disclosure the nightly sweep generates; no document yet)
required, requirement_reason Whether the policy called for one here, and why or why not
jurisdiction, template_version The template it came from
figures, assumptions What the document printed
pdf_sha256, pdf_size_bytes The stored PDF
limit_cents, previous_limit_cents, held_limit_cents, limit_applied_at The limit disclosed, the one before, and an increase held until acknowledgement
delivered_at, delivery_channel When the merchant first opened it on the portal (portal)
acknowledged_at, acknowledged_portal_session_id When, and through which portal link. A keyed hash of the IP is kept, never the IP
superseded_by_id, superseded_at A newer disclosure on the line replaces any earlier one not yet acknowledged
withdrawn_reason Why a held increase was withdrawn or not applied: a later limit change went through, the line was suspended or closed, an operator withdrew it, or the line was activated by override
held_increase_blocked_reason Why the increase it holds cannot be applied now (the program's limit envelope narrowed past it, or the line is suspended or closed); null when acknowledging would apply it, or nothing is held
override_reason, overridden_at, overridden_by_user_id An operator's activation without the acknowledgement: why, when and who

GET /credit/disclosures/{id}.pdf returns the stored document. It is never re-rendered: its SHA-256 is always pdf_sha256, whatever has changed since in the template or the program. A disclosure that was not required has no document (400 CREDIT_616).

Acknowledgement

Under require_acknowledgement:

  • A new line opens pending. It has no available credit, so its cards carry no headroom (see Line status). The merchant opens /p/credit/<token>/disclosure from a portal link that carries the acknowledge scope (see Borrower portal), reads the figures or downloads the PDF, and presses Acknowledge. The line then moves to active (with the reason "Disclosure acknowledged by the merchant on the borrower portal") and its available credit is pushed to the issuer. Until then a PATCH to active is refused (CREDIT_617), unless an operator overrides (below).
  • A limit increase is held. The PATCH, the approved request or the applied review records the new limit on a limit_change disclosure (held_limit_cents); the old limit stays in force and nothing is pushed. When the merchant acknowledges, the new limit is applied through the normal limit path: one credit.line.limit_changed event, carrying disclosure_id, and the push to the issuer. The request or review reads approved or applied meanwhile.
  • A later limit change that goes through withdraws an increase still held (the disclosure becomes superseded, with withdrawn_reason), so an old acknowledgement never undoes a newer decision. So does suspending or closing the line: acknowledging later can never raise the limit of a line someone stopped.
  • A terms-change disclosure waits for the merchant like any other, and acknowledging it is recorded; it holds nothing back, since the program's terms already apply. A limit increase still held when the terms change is carried into it through the same hold: its figures are on the held limit, and acknowledging it applies the increase. A pending line opens when its merchant acknowledges the newest disclosure, whatever its kind.

Acknowledging is all or nothing. It takes the line's lock, as every limit decision does, and reads the disclosure and the line again under it, so a decision that commits meanwhile wins: a disclosure no longer awaiting (already acknowledged, superseded or withdrawn) is never applied a second time, and a double-submitted form applies once and announces once (the credit.line.limit_changed it causes is keyed by the disclosure). Everything that can refuse is checked before anything is written. A held increase the program's limit envelope no longer admits (its limit_max_cents was lowered below it, say) is refused on the page (CREDIT_619) with nothing recorded: the disclosure keeps waiting, its held_increase_blocked_reason says why in the console, an alert goes to your operators, and the merchant can acknowledge again once you widen the envelope, or you withdraw the increase. A refusal at any later step rolls the whole acknowledgement back.

On a line that is suspended or closed, acknowledging records the merchant's acknowledgement of the document and changes nothing on the line: it is never reactivated (whoever stopped it reopens it), and an increase it held is not applied (withdrawn_reason says so, and the event carries not_applied_reason). The page tells the merchant their line is not active.

Acknowledging twice changes nothing. A superseded disclosure cannot be acknowledged (CREDIT_609), nor one that does not need it (CREDIT_610). Mint the merchant a portal link (POST /credit/lines/{id}/portal-sessions) to send them there, after credit.disclosure.issued tells you one is waiting, so the link carries acknowledge; credit.disclosure.acknowledged tells you when it is done.

Activating without the acknowledgement. An operator in the console may open a pending line before the merchant acknowledges (a signed paper copy, say) with acknowledgement_override: { "reason" } on the status change. The disclosure becomes overridden with override_reason, overridden_at and overridden_by_user_id, and never acknowledged_at, because the merchant did not acknowledge it; the change is in the audit trail; credit.disclosure.overridden is emitted; and the line's available credit is pushed. An increase the disclosure held is not applied: the override opens the line, it does not raise it. An API token cannot override (CREDIT_618).

Held increases. GET /credit/compliance/held-increases lists every increase waiting for a merchant, oldest first: the line, credit_limit_cents in force, held_limit_cents, issued_at, delivered_at, and blocked_reason when it can no longer be applied. POST /credit/disclosures/{id}/withdraw { "reason" } (operator action) withdraws one: the disclosure becomes superseded with withdrawn_reason "Withdrawn by an operator: ", the merchant can no longer acknowledge it, and the limit in force stays. Only a held increase still awaiting acknowledgement can be withdrawn (CREDIT_610 otherwise).

Adverse-action notices

On a program with adverse_action.enabled, each of these records one adverse action, once:

Action action Reasons
An underwriting assessment completes with a required criterion failed declined The scorecard's failed criteria, each with its sentence (as in adverse-action reasons)
A limit review lowers the limit (applied directly, or a proposal applied) limit_decreased The review's step-down triggers, each with the figure measured; else the formula it worked from
An approved limit-change request lowers the limit limit_decreased The request's reason
A person suspends or closes the line with reason_code credit_risk, delinquency or fraud line_suspended, line_closed The reason given
The collections ladder suspends the line, with include_delinquency_actions line_suspended How many days past due the account was

A limit lowered directly with PATCH is not one of them. An assessment is attributed to its line's program, or to the program of the scorecard version it ran against; an ad-hoc criteria_override with no line names no program and records nothing.

GET /credit/adverse-actions lists them soonest due first (status, due_before and credit_line_id filter): subject_type and subject_id (the assessment, review, request, or status change), action, action_on, due_on (the action's date plus deliver_within_days), days_until_due, reasons, reason_code, detail, status (pending, sent, waived, not_required), and once sent sent_at, channel, recipient, notice_id, template_jurisdiction, template_version and pdf_sha256. A pending notice that send would refuse with CREDIT_614 carries send_blocked_reason: no_credit_line (a declined application: deliver it yourself and waive it) or no_merchant_email (set the line's merchant_email first); it is null when the notice can be sent. The list pages with next_cursor (limit 1 to 500, default 50).

  • POST /credit/adverse-actions/{id}/send renders the approved adverse_action template (the creditor's identity from the policy, the action and its date, the principal reasons, and how to ask for a statement of them), stores the PDF and emails it to the line's merchant_email as a borrower notice of kind adverse_action with the PDF attached, once. Sending a notice already sent returns it unchanged and emails nothing. A draft template is never used (CREDIT_602); a line with no merchant_email, or a declined application with no line, has no one to send to (CREDIT_614); the email goes once the request has committed, and the response says sent. If the email then cannot be sent the notice goes back to pending with nothing recorded as sent (and no PDF to download), and you press Send again. CREDIT_615 is no longer returned by this call.
  • POST /credit/adverse-actions/{id}/waive { "reason", "outcome"? } closes a pending notice unsent: outcome waived (default: you deliver it yourself, or will not) or not_required (counsel found none owed). reason is required.
  • GET /credit/adverse-actions/{id}.pdf returns the notice as sent.

Sending and waiving are operator actions. The nightly sweep raises an alert for each pending notice due within five days or overdue, and sends nothing.

In the console

The program form has a Compliance section for the policy. Partner Credit shows, once any program's policy is on, a banner listing the open lines that lack their jurisdiction, the Compliance templates panel (status, Preview, Approve with the review fields, Retire), the Notices due queue (Send and Waive, a hundred at a time with Previous and Next) and the Held increases queue (each increase waiting for its merchant, "Cannot be applied: " where it cannot, and Withdraw). Send is disabled, with the reason beside it, on a notice that cannot be sent: an application with no line, or a line with no merchant email. The line form takes the merchant's email, legal name, state and address (an address is saved whole, line 1, city and ZIP code, or not at all), and a line's Settings tab edits the merchant's email and jurisdiction and lists its disclosures with their status, what became of a held increase, Withdraw for one still held, and a download. Change limit on a program that holds increases until acknowledged says the increase is held, not raised, and links to the line's disclosures. A line's header says when it waits for the merchant (the opening disclosure of a pending line, or a held increase, and why a held increase cannot be applied) with Portal link, which mints a link to send the merchant; on a pending line that waits, Activate opens the override dialog, which says what is bypassed and requires a reason.

Error codes

Code Status When
CREDIT_600 400 A compliance-enabled program needs merchant_legal_name, merchant_state and merchant_address on the line (at open, and they cannot be cleared); the message names what is missing
CREDIT_601 400 merchant_state is not a two-letter US state, DC or territory code
CREDIT_602 400 No approved template for a document the policy calls for (line open, limit increase, generating a disclosure, sending a notice); the message names the template to approve
CREDIT_603 400 The approved template's file changed after it was approved
CREDIT_604 404 No such compliance template, or its file is no longer shipped
CREDIT_605 400 The template's status does not allow it: only a draft can be approved, and a retired template cannot be retired again
CREDIT_606 400 Approving a template needs reviewed_by, reviewed_on and signoff_document
CREDIT_607 400 Generating a disclosure on a line whose program has no compliance policy on, or whose disclosure mode is off
CREDIT_608 404 No such disclosure
CREDIT_609 400 (page) The disclosure was replaced by a newer one
CREDIT_610 400 (page) The disclosure does not need an acknowledgement; or withdrawing a disclosure that is not a held increase awaiting acknowledgement
CREDIT_611 404 No such adverse action
CREDIT_612 400 The adverse action is not pending (sending or waiving one already waived or found not required, or waiving one already sent)
CREDIT_613 400 Waiving a notice, or recording that none is required, needs a reason
CREDIT_614 400 There is no one to send the notice to: no credit line, or no merchant_email on it
CREDIT_615 400 No longer returned: the notice email is sent after the request commits, and a failed one puts the notice back to pending
CREDIT_616 400 No document yet: a disclosure that was not required, or a notice not yet sent
CREDIT_617 400 Activating a pending line whose disclosure awaits the merchant's acknowledgement; an operator may send acknowledgement_override
CREDIT_618 403 acknowledgement_override from an API token
CREDIT_619 400 (page) The held limit increase can no longer be applied (the program's limit envelope narrowed past it); nothing was recorded

The scorecard

The scorecard is the partner's (O-01): keys, comparators, thresholds, weights, and which criteria are required. DayZero measures.

Versions

A program's scorecard is a sequence of versions. A version is edited while it is a draft, then validated, approved, and made active; the version it replaces becomes retired. Once a version leaves draft it never changes, so changing the rules means a new draft copied from the current ones. Every assessment records the version it ran against.

Call Who What it does
GET /credit/programs/{id}/scorecards Any Scorecards on the program, each with its versions
GET /credit/scorecard-versions/{id} Any One version: criteria, manual checks, grade bands, scoring, and the last validation report
GET /credit/scorecard-versions/{a}/diff/{b} Any What was added, removed or changed from a to b
POST /credit/scorecard-versions/{id}/validate Writer (a credit token or a member who can write; not credit:read, not a viewer) Validates and stores the report; a clean draft becomes validated
POST /credit/scorecard-versions/{id}/preview Any Measures one business against the version and returns the assessment shape. Nothing is saved
POST /credit/programs/{id}/scorecards, POST /credit/scorecards/{id}/versions, PATCH and DELETE /credit/scorecard-versions/{id} Operator Create a scorecard, open a draft, edit it, discard it. Editing anything but a draft returns 409
POST /credit/scorecard-versions/{id}/approve Operator Records your sign-off: approver_name, note, optional attachment_s3_key
POST /credit/scorecard-versions/{id}/activate Operator New assessments use this version from now on. Existing assessments keep theirs
POST /credit/scorecard-versions/{id}/backtest Operator Runs the version over a cohort of businesses, asynchronously

PATCH /credit/programs/{id} with underwriting_criteria still works and still takes effect at once: it is recorded as a new active version. The program's underwriting_criteria always reads as the active version's criteria.

In the DayZero console a version can be exported as a JSON file and a draft's rules replaced from one (Export JSON / Import JSON… on the version page). The file is the four sections (criteria, manual_checks, grade_bands, scoring); a bare list of criteria and the operator seed file (limit_criteria on top of the program layer) are read too. An import changes the draft on screen only until it is saved, and the validator reports what the file left unfinished.

Start from a seed

A program with no underwriting criteria and no active version cannot score an assessment. So that underwriting is never left undecided while the partner's own scorecard is awaited, POST /credit/programs/{id}/scorecards/from-seed with { "seed": "tie" } (operator only) creates a draft from a seed DayZero ships: tie is Tie Capital's whole launch scorecard: ten fixed-threshold rules (the program layer, the same ten as scripts/admin/credit/scorecard_example.json), three rules that scale with the requested limit, and five manual checks. With no scorecard yet, the draft is the first version of a new default scorecard named for the seed, holding all thirteen rules, and program_layer_source says the seed's own criteria were used. With one, it is a new draft on the default scorecard: the active version's criteria are the program layer instead of the seed's ten, and the seed's limit-scaled rules are added on top (a seed rule replaces a program rule only when they share an id). The definition goes through the same importer a JSON import does. The version's origin is seed. It is a draft like any other, so it is validated, approved and activated in the console, which is where the partner's approval is recorded. Sending it again while that draft is open and unchanged returns it with created: false. In the console, Start from the Tie seed is on the scorecards page while the program has none.

Code Meaning
CREDIT_678 The program's scorecard already has a different open draft: edit or discard it first
CREDIT_679 The seed does not import (the message carries the importer's reason)

Scorecard changes are not on the event feed. They are kept in an audit trail (GET /credit/scorecards/{id}/audit): who created, edited, validated, approved and activated each version, and what changed.

Criteria

json
{
  "id": "crit_cash_vs_limit",
  "key": "average_daily_cash_balance",
  "label": "Average daily cash (90d) ≥ 50% of limit",
  "comparator": "gte",
  "unit": "currency",
  "threshold": { "basis": "requested_limit", "value": 0.5 },
  "weight": 3,
  "required": { "when": "always" },
  "applies_when": null,
  "policy_reference": "§7"
}
Field Meaning
key The measure, from GET /credit/underwriting/measurers
comparator gte, gt, lte, lt, eq, ne, or between (threshold [low, high])
threshold.basis absolute: value is the threshold. requested_limit: the threshold is value × the requested limit. A rule stated per month is written pre-multiplied: 1.33× the limit per month over 12 months is 15.96
weight Points the rule is worth, 0 or more
required {"when": "always"}, {"when": "never"}, or a condition. A failed required rule is listed in required_failed
applies_when A condition, or null for always. A rule that does not apply is not scored

A condition compares a fact about the application: {"when": "requested_limit", "op": "gt", "value": 100000}. The facts are requested_limit (whole currency units) and requested_term_days; the operators are gt, gte, lt, lte, eq, ne, and between. The original form is still accepted everywhere and means the same: a plain number for threshold is an absolute threshold, and true or false for required is always or never.

Thresholds, measured values and condition values are whole currency units, because that is what the measurers return. requested_limit_cents on a request is cents, like every other amount in this API. GET /credit/underwriting/scorecard-vocabulary returns every comparator, unit, basis, condition fact and operator a scorecard may use.

Scoring

score_bps is the weight of the criteria that passed over the weight of the criteria that count, in basis points. Which count is the version's scoring.unmeasured_policy: exclude_from_denominator (the default) leaves unmeasured criteria out, so a merchant is scored on what is known; count_as_zero counts their weight as not earned. A rule that does not apply never counts. grade_bands ([{"min_score_bps": 8000, "label": "A"}, …]) are optional; with them, the assessment carries the grade whose floor the score reaches.

Validate and preview

plaintext
POST /credit/scorecards/validate
{ "underwriting_criteria": [ { "key": "trailing_12m_revenue", "comparator": "gte", "threshold": 500000, "required": true }, … ] }

The response says whether the set is well-formed, which keys DayZero can measure today, and which required keys it cannot (an assessment can never pass those). messages addresses each finding to its row and field, and context_fields_required names what an assessment must supply (requested_limit) for every rule to be scored. Pass "strict": true to apply the rules a version must meet to be approved: a required criterion with no measurer and a unit that disagrees with its measurer become errors.

plaintext
POST /credit/scorecard-versions/{id}/preview
{ "business_id": "...", "requested_limit_cents": 25000000, "requested_term_days": 60 }

returns what an assessment would (criteria with resolved thresholds, score, grade, required_failed, manual checks) and saves nothing. It does not return adverse_action_reasons: read the failed criteria from criteria[]. It works on any version, drafts included. business_id must be a business your token can access.

GET /credit/underwriting/measurers lists every measurable key. An example scorecard that measures fully is at scripts/admin/credit/scorecard_example.json.

Underwriting measurers

Criteria keys with a built-in measurer: trailing_12m_revenue, trailing_3m_revenue, average_daily_cash_balance, existing_debt_facilities, debt_service_coverage_ratio, revenue_concentration_top_customer, months_of_history, months_in_business, trailing_12m_ad_spend, ad_spend_to_revenue_ratio, ad_spend_to_revenue_ratio_90d, shopify_trailing_12m_revenue, shopify_refund_rate, shopify_chargeback_rate, shopify_revenue_growth_yoy, shopify_trailing_90d_sales, nsf_count_12m, cash_runway_months, credit_utilisation_90d, credit_repayment_performance_180d. GET /credit/underwriting/measurers returns each with its unit, source, lookback_days, the required_connections a merchant needs for it to produce a figure, and the evidence_schema it returns. Ratios are decimals (0.05 = 5%); money is in whole currency units; revenue_concentration_top_customer is a percentage (41 = 41%). A key without a measurer is reported unmeasured, never failed, and a measurer that lacks the data returns no figure rather than zero, with the reason in unmeasured_reason (no_shopify_connection, insufficient_history, no_bank_connection, …).

Short history reads as unmeasured, never as a low figure. The windowed money measures (trailing_12m_revenue, trailing_3m_revenue, trailing_12m_ad_spend, ad_spend_to_revenue_ratio, debt_service_coverage_ratio, shopify_trailing_12m_revenue, shopify_trailing_90d_sales) need the business's history (its first journal entry or bank transaction; for the Shopify ones its first day of Shopify sales) to cover at least 90% of their window; otherwise they are unmeasured with insufficient_history, and the evidence gives days_available and days_required. average_daily_cash_balance averages only days from the first bank data on and needs at least 60 of them, the same rule cash_runway_months follows. nsf_count_12m without a bank connection is unmeasured with no_bank_connection, not 0. months_in_business reads the business's stated start date, else its first Shopify order, else its first bank transaction; evidence.source says which (business_profile, shopify_orders, bank_transactions). revenue_concentration_top_customer reads invoices; a business with none is measured from its Shopify orders instead (the top customer's share of net sales over 365 days, by Shopify customer id or email, with evidence.source: shopify_orders), and with neither it is unmeasured with no_invoiced_revenue.

Three measurers have conventions worth knowing. cash_runway_months is the average bank balance over 90 days divided by the net monthly fall in that balance; a business whose balance is not falling reports 999 with evidence.cash_flow_positive: true, and one with fewer than 60 days of bank data is unmeasured. shopify_revenue_growth_yoy is net sales over the last 365 days against the 365 before, as a decimal, and is unmeasured with less than 24 months of Shopify history. ad_spend_to_revenue_ratio_90d is Advertising and Marketing ledger spend over revenue for the last 90 days, and is unmeasured when either is absent. average_daily_cash_balance is a true 90-day daily average: the nightly sweep snapshots every linked bank account's balance, with a live Plaid refresh, for the lender's business when it has a line that can spend, for the merchant business behind each line that can spend (merchant_business_id), and for the merchant business each pending assessment measures (subject_business_id), each merchant on its own so one failure does not stop the others; days without a snapshot are reconstructed from bank-ledger postings, and the evidence says how many days came from each source.

Two more have conventions worth knowing. shopify_chargeback_rate is the chargebacks over the orders of the last 365 days. Only disputes Shopify types chargeback count; an inquiry (the cardholder's bank asking before it decides) is reported in the evidence and not counted. The daily Shopify sync stores the store's disputes back 400 days (the window and a month more), and the dispute webhooks keep them current between syncs, so until a completed sync has pulled the disputes back over the whole window the rule is unmeasured with dispute_history_incomplete, never a passing zero. Its evidence is chargebacks, inquiries, orders, window_start and disputes_synced_on. debt_service_coverage_ratio counts the active borrowings on the merchant's debt schedule and leaves out a facility deleted from it, as existing_debt_facilities does.

Two measurers read the credit book DayZero keeps rather than the merchant's accounts, so they need no connection (required_connections is empty). credit_utilisation_90d is the average of each day's outstanding balance (principal, fee and interest, by the ledger's effective dates) over the last 90 days, divided by the limit, as a decimal; it is unmeasured on a line open fewer than 30 days (insufficient_history). credit_repayment_performance_180d is the share of the loans that came due in the last 180 days (before the day measured) that were repaid in full on or before their due date; its evidence gives loans_counted, loans_on_time, loans_paid_late, loans_open_past_due, write_offs and written_off_cents, and days_late_p50 and days_late_p90 (0 for a loan paid on time; a loan still open counts its days past due), and it is unmeasured with no_loans_due when nothing came due. Both read the line a limit review or an assessment names; an assessment without a line reads your lines whose merchant_business_id is the business measured, and with neither they report no_credit_line. shopify_trailing_90d_sales is Shopify gross sales over the last 90 days, read from the same daily summaries as the 12-month figure.

API tokens

Create a token under Settings › Developers. Use a separate key per job:

Job Scope IP restriction
Merchant dashboard credit:read Optional
Payment posting (Payliance / ACH): quote, then record credit:payments (also reads GET /credit/lines/{id}/payoff-quote and GET /credit/lines/{id}/payments) Restrict to the job's egress IPs
Broader credit integration credit Optional; prefer IPs on any write token

A credit:read token's only writes are minting and revoking borrower-portal links (POST /credit/lines/{id}/portal-sessions, DELETE /credit/portal-sessions/{id}), which it can grant the portal's read scopes only (see Borrower portal). credit / credit:read / credit:payments tokens are refused (403) outside their surface: /api/v1/credit plus the credit webhook endpoints under /api/v1/webhooks/o2c for credit and credit:read (see Webhooks), and everything under /api/v1/credit/payments for credit:payments: recording, previewing (POST /credit/payments/preview), settling and reversing a payment, and resolving which line a payment belongs to (GET /credit/payments/resolve), plus two reads a payment job needs before each debit: the line's payoff quote (GET /credit/lines/{id}/payoff-quote) and its payment list (GET /credit/lines/{id}/payments). Those two are GET only; anything else under /credit/lines is refused, so pair it with a credit:read token for more. They cannot be minted together with api or read_only, which would lift that limit. A token minted for specific businesses keeps that limit when it is rotated. Tokens with no scope keep full access. Creating or rotating a token requires two-factor authentication: the DayZero account must have a second factor enrolled, and the web app asks you to verify both factors when you generate the key (it cannot be done with an existing API token). Listing and revoking tokens do not. Using a token needs it too: if the account that owns a token removes its second factor, the token's requests are refused with 403 and code AUTH_085, and DayZero revokes that owner's tokens (and disables API clients they own) as soon as it learns of the change, then emails the owner. Turning two-factor back on does not revive them; create a new key. To rotate, mint the new key, swap the caller, then revoke the old one, or call POST /api/v1/tokens/{id}/rotate (also behind the second-factor check), which mints the replacement and keeps the old token valid for overlap_hours (default 24, maximum 168; 0 revokes it at once).

Code Status Meaning
AUTH_085 403 The token's owner no longer has a second factor on their DayZero account; the token is refused and revoked

Two credentials never work here: an MCP OAuth token, which the REST API rejects with the same 401 as an expired token, and any API token on an operator action (see Operator actions), which answers 403.

Signed payment requests

A bearer token alone lets anyone who captures one request alter or replay it. A partner API client (the machine identity behind a client-credentials token) can therefore be set to sign its payment writes. The check covers exactly four calls: record (POST /credit/payments), preview (POST /credit/payments/preview), settlement (POST /credit/payments/{id}/settlement) and reversal (POST /credit/payments/{id}/reversal). No other call is checked: reads (GET /credit/payments/resolve included) are never signed, and signing headers on any other request are ignored.

Who must sign follows the credential:

  • a token issued to an API client that requires signed requests: every payment write must carry a valid signature, or it is refused;
  • a token issued to an API client that has signing keys but does not require them: a signature is optional, but one that is sent is verified, and a bad one is refused;
  • any other credential (a token minted in Settings › Developers, a browser session): nothing to sign, and signing headers are ignored.

Register the key with DayZero (an HMAC secret, or the raw Ed25519 public key; several keys can be live while you rotate) and send four headers:

Header Value
X-DayZero-Key-Id The kid of a live key. A retired or unknown one is refused.
X-DayZero-Timestamp Unix seconds when you signed. It must be within 300 seconds of the server clock, either way.
X-DayZero-Nonce 16 to 128 characters of A-Z a-z 0-9 _ -, new on every request. A nonce is accepted once per client for 900 seconds.
X-DayZero-Signature v1= and the signature of the string below: lowercase hex of HMAC-SHA256 for an hmac-sha256 key, unpadded base64url for an ed25519 key.

The signed string is UTF-8, seven lines joined by \n, with no trailing newline:

plaintext
v1
{METHOD}
{path}
{query}
{sha256 of the body, lowercase hex}
{timestamp}
{nonce}

METHOD is upper case. path is the request path as sent, percent-encoding untouched, without the query. query is the query string split into name/value pairs, sorted by name and then value, each percent-encoded leaving only A-Z a-z 0-9 - _ . ~ bare, joined as name=value with &; no query is the empty line. The body hash is of the raw bytes you send (the empty body hashes to e3b0c442...b855), so sign the exact serialisation you transmit, not an object you serialise later.

python
import hashlib, hmac, json, secrets, time
from urllib.parse import parse_qsl, quote

import httpx


def canonical_query(raw: str) -> str:
    pairs = sorted(
        (quote(k, safe="-_.~"), quote(v, safe="-_.~"))
        for k, v in parse_qsl(raw, keep_blank_values=True)
    )
    return "&".join(f"{k}={v}" for k, v in pairs)


def signed_post(base_url, path, payload, token, business_id, kid, secret):
    body = json.dumps(payload).encode()
    ts, nonce = str(int(time.time())), secrets.token_urlsafe(24)
    canonical = "\n".join(
        ["v1", "POST", path, "", hashlib.sha256(body).hexdigest(), ts, nonce]
    )
    signature = hmac.new(
        secret.encode(), canonical.encode(), hashlib.sha256
    ).hexdigest()
    return httpx.post(
        base_url + path,
        content=body,
        headers={
            "Authorization": f"Bearer {token}",
            "x-business-id": business_id,
            "Content-Type": "application/json",
            "X-DayZero-Key-Id": kid,
            "X-DayZero-Timestamp": ts,
            "X-DayZero-Nonce": nonce,
            "X-DayZero-Signature": f"v1={signature}",
        },
    )

For an Ed25519 key, sign the same string with the private key and send "v1=" + base64.urlsafe_b64encode(sig).rstrip(b"=").decode(). In TypeScript, the same string is const canonical = ["v1", "POST", path, query, sha256Hex(body), ts, nonce].join("\n"), signed with crypto.createHmac("sha256", secret).update(canonical).digest("hex") or crypto.sign(null, Buffer.from(canonical), privateKey).

The Python SDK does this for you: CreditClient(base_url, token, business_id, signing_key=SigningKey(kid="k1", secret="...")) signs every payment write (an ed25519 key takes private_key= instead) and signs each retry afresh, since a nonce is single-use.

A refused request is 401. A request that fails verification is not applied, does not consume its nonce, and counts toward a signature_invalid alert on the token (three in ten minutes alert the operator).

Code Status Meaning
AUTH_062 401 This client must sign payment requests and the request carried no signature headers
AUTH_063 401 The signature is invalid: a header missing or malformed, an unknown or retired key id, or a signature that does not match the method, path, query, body, timestamp and nonce
AUTH_064 401 The timestamp is outside the accepted window; check your clock (stale or too far ahead)
AUTH_065 401 The nonce was already used; sign each request, and each retry, with a fresh nonce

Billing and token access

An API token is refused (403 AUTH_072) on a business whose billing is not active: a subscription that is past_due, unpaid or canceled, or a trial that has ended. Every /credit call is also refused (403 BILLING_003) on a business without the CFO Suite add-on; a trial unlocks every add-on, so a trial ending takes both away. For a lender either is every partner call failing at once, so it is planned for in four ways:

  • Partner billing. A partner billed under a contract rather than through Stripe is put on the partner_program plan by DayZero staff, with the agreement's reference (partner_agreement_ref, required, 1 to 255 characters; 400 CREDIT_583 without it). The plan passes the billing gate with no subscription and no trial, so its tokens never lapse on a date, and it includes CFO Suite whatever its add-on lists say, so a lender moved onto it from a trial keeps every /credit call working. Stripe and the nightly billing reconciliation leave its status and its plan alone, a trial banner never shows, and any other add-ons the agreement covers are recorded as its included add-ons. Nothing on the business can start or change a Stripe subscription: Settings › Billing says the plan and add-ons are set by the agreement and offers no checkout and no add-on switch, and checkout, the add-on preview and toggle, and a plan change return 400 BILLING_023. The Stripe billing portal stays open, to cancel a subscription the business had before the agreement. The agreement's reference is not shown to the business. Sandbox, firm-billed (firm_client) and grandfathered businesses pass the billing gate the same way.
  • Keeping CFO Suite. A staff change to a business's billing that would take CFO Suite away from a business that operates credit (any credit program or line, in any state) is refused (400 CREDIT_585) unless the same change keeps it, in the paid or the included add-ons, or by moving to partner_program. Nothing in a refused change is written.
  • The daily alert. Every day DayZero checks each business that operates a credit program in use: an active one, or one holding a line that is not closed (lines open under a draft program, and a paused program still has merchants). One whose access has lapsed (its billing refuses its tokens, or it lacks CFO Suite), or that passes only on a trial ending within 14 days, gets one operator alert a day: in the app, and on the alert_slack_channel and alert_emails of its first program in use that has either. It says when the access stops and what to do (move to partner_program, which includes CFO Suite, or add a payment method and keep CFO Suite).
  • Asking. GET /credit/billing-lapse-risk (operator; within_days 0 to 365, default 14) returns at_risk and, when at risk, detail: state (lapsing: tokens work until the trial ends; lapsed: they are refused now, by the billing gate or for want of CFO Suite), billing_status, plan_tier, trial_ends_at, days_left, a reason and active_programs, which despite its name lists every program in use (active, or holding a line that is not closed). A business with no program in use, on a subscription that includes CFO Suite, on the partner_program plan, or a sandbox is never at risk.
Code Status Meaning
AUTH_072 403 The business's billing is not active, so its API tokens are refused
BILLING_003 403 The business does not have the CFO Suite add-on, which every /credit call needs
BILLING_023 400 Checkout, an add-on change or a plan change on a business billed under a partner agreement
CREDIT_583 400 (staff) partner_program without a partner_agreement_ref
CREDIT_585 400 (staff) A billing change would take CFO Suite away from a business that operates credit

An archived business

DayZero staff can archive a business (or the advisory firm it is reached through) that should not be in use. Nothing in it is deleted or cancelled, but while it is archived every request for it, from the web app or an API token, is refused with 403, it drops out of business lists, and the nightly sweep and every other scheduled job skip it, so a lender's credit book on it stops processing. A business operating an active credit program is archived only when staff confirm that on purpose. When it is unarchived, the same tokens work again and the next nightly run picks it up. If you see either code, contact DayZero support.

Code Status Meaning
AUTH_073 403 The business is archived
AUTH_074 403 The advisory firm is archived, so its staff cannot reach the business through it

Validate end to end

Replay the FRD Appendix B book (five March swipes, March close, a $30,000 payment on 15 April, April close) against a sandbox tenant. Every check prints PASS or FAIL; the process exits 0 only when the figures match.

bash
python scripts/admin/credit/validate_tie_e2e.py \
  --base-url https://api.ondayzero.com \
  --token "$DZ_TOKEN" \
  --business-id "$SANDBOX_BUSINESS_ID" \
  --apply

The business must be a credit sandbox: the walk writes a throwaway book, so the script reads GET /credit/environment first and refuses any business that is not one. It runs the same walk in two modes, over HTTP as above, or in-process against the app (leave out --base-url and --token; DayZero runs it that way), and every step is judged in both. It prints PASS or FAIL for, among the rest: the program's interest ceiling (100% of principal, D-50); a 45-day term accepted and a 37-day term refused with CREDIT_513; the March statement closed and issued 1 April and due 4 April, and April closed, issued and due 1 May; each loan's interest to 15 April ($72.33 and $23.29 on the first two, nothing on the other three); a pending debit leaving Total Balance where it was; the $30,000 split per loan, interest then fee then principal, as Appendix B.2 gives it; March then reading $29,904.38 paid and $15,545.62 outstanding, and April $15,581.97; a refund against loan 4 leaving March at $45,450.00 and April at $15,581.97; a card issued on the mock issuer, with an embed token; and a payment return that puts Total Balance back. When the walk ends it posts every step to POST /credit/sandbox/walks, so the live business's launch checklist reads it as the last walk; it passes only if every step passed.

The same walk is the CI contract in app/api/v1/tests/test_credit_tie_lifecycle.py. Without --program-id the script creates a throwaway program with the FRD's terms, the interest ceiling and the mock issuer; creating a program is an operator action, so over HTTP with an API token pass --program-id naming a sandbox program that carries those settings, or the checks against them fail. Omit --apply for a dry run of the plan.