Skip to content

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, which it records on the assessment (in the console or with the API). 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 queues issuer controls derived from committed available credit. Issuer delivery is asynchronous: a saved balance or queued control is not confirmation that a card's limit has changed.

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. API access is off by default for every account: ask for it under Settings › Developers (POST /api/v1/tokens/access-request with use_case_kind, one of internal_automation, reporting_sync, firm_client_workflows, product_integration, ai_agent, migration, evaluating or other; use_case is optional with a listed kind and must be at least 20 characters without one or with other; company_name is optional). An owner or admin of a business with the CFO Suite add-on who has two-factor authentication enrolled is approved immediately (and, if they never asked, on their first API use); anyone else waits for a DayZero operator to decide the request, and you are emailed either way; GET /api/v1/tokens/access reports where it stands. Until it is approved, minting a token or registering an API client is 403 (AUTH_069); the sandbox can still be provisioned meanwhile, with Create without token (see Sandbox). 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_815 404 No such in-app notification on this business
CREDIT_816 400 A kind filter on a notification list names a kind that does not exist; the message names it
CREDIT_817 400 The business already has 10 webhook endpoints subscribed to credit events
CREDIT_818 403 An operator holds the program's event_delivery_mode (event_delivery_locked); the partner cannot change it
CREDIT_819 403 The program's events are delivered in webhook mode, which an operator sets; the partner cannot change it
CREDIT_820 400 The push was turned on (both) with no active webhook endpoint subscribed to a credit event to push to
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, notifications, adjustments)
CREDIT_500 400 The program does not have card issuing enabled
CREDIT_501 400 The program has no issuing identity (ramp_cardholder_user_id), so there is no Ramp user to issue the card to. On Stripe Issuing only with issuer_config.cardholder_mode: shared and no cardholder set; otherwise a cardholder is created per card
CREDIT_502 400 The card issuer refused the request; the message carries the issuer's reason
CREDIT_834 400 A card cap is more than the card can spend; the message says why: the program is not active (a draft program's lines cannot spend until it is activated), issuing is off, the line is not open, or the most it can spend
CREDIT_835 400 A disclosure list status filter names an unknown status
CREDIT_836 404 GET /credit/merchant-data names a merchant you have no business for yet
CREDIT_701 400 ramp_cardholder_user_id is not a user on the connected Ramp account, or there is no connection to check it against
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_552 409 A decision was recorded on an assessment that is not completed (pending or failed); the response names assessment_status
CREDIT_553 409 The assessment already has a different decision; one is recorded per assessment and it cannot be reversed
CREDIT_554 400 A decision body the assessment cannot take: a limit or term on a decline, or an approval with no limit to approve
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 The line or application has no valid merchant email
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
CREDIT_740 409 GET /credit/issuer/cards with source=live on a business with no Ramp connection
CREDIT_741 503 GET /credit/issuer/cards with source=live: Ramp did not answer within 20 seconds, refused the call, or the connection lacks cards:read. Retry, or read source=stored
CREDIT_742 400 An issuer list's start is after its end
CREDIT_751 400 A payment's paid_on (or a pending payment's settled_on, or a preview's as_of) is before the line's cut-over, the day its loans were imported; the message gives the earliest date allowed
CREDIT_750 400 An event_types name on GET /credit/events or GET /credit/event-deliveries is not an event type; the message lists each name it does not know. Send the names in the event vocabulary
CREDIT_755 400 A partner-export seed sent opening_loans without opening_as_of
CREDIT_756 400 A partner-export seed's opening_as_of is not before every transaction, payment and write-off in it
CREDIT_757 400 A partner-export seed for the same program is already queued or running; poll its job
CREDIT_760 409 A sandbox reset or line delete was sent while a sandbox job (a seed or a generated book) is still queued or running; the message names the job
CREDIT_761 400 A sandbox line delete was sent without confirm set to the line's id or "DELETE"
CREDIT_770 400 A migration build's since is after its pull_date
CREDIT_771 400 A migration build from a pull gives no loan to import and no line to open
CREDIT_772 400 A migration build's mapping names a field the builder does not read
CREDIT_773 400 A cut-over migration build from a pull with no merchants table
CREDIT_790 400 A history file's kind is not payments or statements
CREDIT_791 400 An import batch holds another kind of file than the route runs (a history file on the loan routes, or the reverse)
CREDIT_792 400 A statement document was refused: not a PDF, over 10 MB, too many in one upload, or sent to a payments file
CREDIT_793 400 A historical payment is already marked reversed
CREDIT_794 400 A statement attached from the previous system has its figures only, no document

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. Show the merchant the last figures you have with their as_of timestamp rather than zeroes while a call is failing.

Reference 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.
  • Postman: docs/api/credit.postman_collection.json. Set the baseUrl, token, and businessId collection variables.

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. On a sandbox business, importing loans (onto a line, and the loan migration wizard), writing off a loan and adjusting a loan are open to any token, with no two-factor step-up, so a partner can rehearse a migration end to end from a script; on a live business they stay operator actions with the step-up below. 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.

On a live business the loan migration wizard is operator-only from end to end (on a sandbox any token may run it): 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). So is attaching the history beside it: uploading a payments or statements file (POST /credit/programs/{id}/history/import/parse), adding statement documents (POST /credit/import-batches/{id}/documents), the dry run and the apply (POST /credit/programs/{id}/history/import), its report (GET /credit/import-batches/{id}/history-report), and marking a historical payment returned (POST /credit/historical-payments/{id}/reversal). Reading the attached history is open to your token. Building the loan file from a stored partner pull (POST /credit/programs/{id}/loans/import/from-partner-pull) is sandbox only, as pulls are. See Launching with existing loans and Attaching the history.

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/lines/{id}/notifications, GET /credit/notifications 50 500 cursor (time and id) 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/compliance/held-increases 50 500 cursor (offset) no
GET /credit/sandbox/partner-pulls 50 200 cursor (offset) yes
GET /credit/programs/{id}/import-batches 20 100 cursor (offset) no
GET /credit/readiness 20 100 cursor (offset) no
GET /credit/issuer/card-transactions 100 500 cursor (date and id) yes
GET /credit/issuer/cards, GET /credit/issuer/cardholders 100 500 cursor (offset) yes
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, notification 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, payment aliases, registered partner pulls and the issuer lists (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": "northwind",
    "display_name": "Northwind Capital",
    "status": "draft",
    "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": "Northwind Lending LLC dba Northwind Capital",
    "statement_branding": {"accent_hex": "#5B21B6", "footer_text": "Questions? support@example.com"},
    "covenants": {"cash_covenant_bps": 5000}
  }'

Create the program as draft and move it to active with PATCH once the launch checks pass: on a live business, creating it active runs the same checks and is refused (CREDIT_660) while a blocking one fails (a sandbox is not gated).

Program parameters

Field Meaning
status draft, active, paused or retired. Lines open under draft and active programs, but on a live business your token cannot open a line on a draft program (400, CREDIT_524) until DayZero activates it; an operator can, which is how the loan migration imports onto a draft. On a live business every line on a program that is not active reports available_credit_cents: 0, and creating a program as active or a PATCH that moves one into active is refused (CREDIT_660) while a blocking launch check fails
force_activate POST and PATCH, 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 at zero capacity and queues delivery of their share of the line's available credit. partner: you provision cards in the issuer, register them with POST /credit/cards/register, and manage the aggregate line budget 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. Your token may switch it between polling and both itself with PUT /credit/programs/{id}/event-delivery (Turn the push on)
event_delivery_locked false (default). true holds event_delivery_mode where DayZero set it: PUT /credit/programs/{id}/event-delivery is then 403 CREDIT_818
bank_link_redirect_uris The https URLs (at most 10, no query or fragment) a merchant's hosted bank Link may return to when it finishes: the completion_redirect_uri values POST /credit/bank-connections accepts, matched exactly. On the sandbox http://localhost and http://127.0.0.1 are allowed too (400 CREDIT_789 on a live program). Null (the default) allows none. See The merchant's bank connection
issuing_enabled, ramp_spend_program_id, ramp_cardholder_user_id Whether DayZero issues and manages cards for the program's lines (default false); the Ramp spend program the cards' funds are created under; and the issuing identity, the one user on your Ramp account every DayZero-issued card is issued to (verified against the connected Ramp account when set, CREDIT_701 otherwise). Moving a live program to active needs all three where DayZero manages the cards (see Launch readiness)
issuing_provider ramp (default); stripe_issuing (see Stripe Issuing); brex (cards whose charges arrive through Plaid); lithic; external (you provision the cards and register them); or mock for an in-memory issuer on staging and sandboxes (Ramp has no sandbox). issuer_config carries the issuer's settings. Stripe live card operations require an owned connected account and the server's Stripe credential; there is no platform-account fallback. Account locators include stripe_account_id, connected_account_id and line_stripe_accounts. default_cardholder_id supplies an ich_... issuing identity; without one the adapter creates a cardholder. funding_credit_enabled enables Stripe credit-policy integration where Stripe has enabled that preview. Sandbox Stripe card operations always use the credential-free mock
issuer_timezone The IANA timezone whose calendar day is the issuer's posting date (D-12), default America/New_York. Ramp's settlement_date timestamp is read in it to give a cleared transaction's settled_on, and so its statement period (see A card transaction posts). An unknown zone is refused with 400. A change reaches settlements recorded after it; a recorded settlement keeps its date
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; and in_app (on unless set false), which records an in-app notification for every one of them whether or not its email is on. 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
auto_approve_default What an assessment request that omits auto_approve asks for. false (the default): the lender records each decision. true: DayZero opens the merchant's line itself when the completed assessment passes the gate, exactly as auto_approve: true on the request does, and the assessment's auto_approval.source reads program_default. An auto_approve sent on the request, true or false, always wins. A program that approves by default should size its lines: the launch check policy.auto_approve_default warns (never blocks) while the active scorecard has no limit_sizing. 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
statement_balance_mode, allocation_scope, cycle_boundary_basis, cycle_close_rule, cycle_anchor_day, excess_credit_disposition The cycle positions a program records (the program's policy reports them): statement_balance_mode cumulative (default) or cycle_scoped; allocation_scope per_cycle (default) or across_line; cycle_boundary_basis issuer_clearing_date (default), merchant_local or utc; cycle_close_rule calendar_month_end (default), anchor_day or operational_trigger, with cycle_anchor_day (1 to 31; also settable on a line) for anchor_day; excess_credit_disposition carry_forward (default) or refund_merchant. A per-transaction program closes on the last calendar day of the month
gl_posting_enabled Post balanced journal entries on the operator's books for every ledger event. gl_funding_ledger_id (credited at origination; default Card Issuer Payable) and gl_cash_ledger_id (debited on repayment; default Cash) choose the ledgers
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: card: {} is enough, with optionally display_name (the card's label at the issuer; the default is the merchant's legal name), card_type (virtual, the default, or physical), spend_interval (TOTAL only) and transaction_limit_cents. A Ramp card uses the program's issuing identity (ramp_cardholder_user_id): one user you control on your own Ramp account, set once on the program and verified against Ramp when set. The merchant is never a Ramp user and the request names no cardholder (cardholder_provider_user_id is deprecated and ignored when the program has an identity). 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 an initial ceiling of zero. Its share of committed line headroom is queued for delivery, and the response carries the card and its issuer_sync_status. If the issuer refuses issuance, 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. This is ledger headroom, not proof of current issuer capacity; inspect card delivery status and the issuer-reported ceiling separately
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
statement_due_date Due date of the most recent statement, fixed at close (see Statements)
delinquency_stage, delinquency_days Where the line sits on the collections ladder, as the nightly collections step last set it: the stage's label (else <days_past_due>d), null when no stage applies, and the days past due of its oldest past-due loan that is not on hold (0 when current). See What runs on DayZero's schedule
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, stripe_issuing, brex, lithic, external 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 has spendable ledger headroom. A pending, suspended or closed line has zero available credit, and a partner-managed line's credit.line.limit_changed carries zero. For DayZero-managed cards, the status transaction queues a durable line-level issuer recomputation; it does not synchronously change every card. After commit, the worker freezes the managed funds, installs zero capacity for a stopped line, and leaves them frozen. Activation or reinstatement queues a fresh allocation and resumes eligible cards only after delivery succeeds. Repeated changes coalesce into the same request and are recomputed from current committed state. Until delivery completes, do not describe a line status response as an issuer-confirmed suspension or restored card availability. An explicit card suspension remains a synchronous decrease; see Cards.

What suspension and closure stop, and what they do not. Suspending a line removes its ledger headroom and queues the issuer stop described above; 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 has no spendable headroom until 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

limit_change_reason (up to 255 characters) on the PATCH says why the limit moved; it is carried on the event and the ledger. 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
applied_limit_cents The limit the approval set: requested_limit_cents, or the approver's own limit_cents. Null unless approved
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 issuer-control recomputation is queued. The request reads approved, with applied_at set; that timestamp records the local limit change, not issuer delivery. limit_cents approves a different figure inside the envelope: requested_limit_cents keeps what was asked, applied_limit_cents holds the figure applied, and decision_note begins "Applied $280,000.00 instead of the $300,000.00 requested." Rejecting leaves the limit alone. Every way a request closes (approved, rejected, cancelled, expired) emits credit.limit_change_request.decided once.

  • 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.

The loan's settled_on, and so its statement period, is the date the transaction posted at the issuer (D-12), in the program's issuer_timezone (default America/New_York). Ramp sends settlement_date as a timestamp, UTC when it has no offset; DayZero reads it in that zone, so a charge Ramp settles at 2026-04-01T01:30:00Z (21:30 on 31 March in New York) is dated 31 March and belongs to March. A date with no time is taken as given. Without a settlement_date the authorisation time stands in, then the time Ramp created the record, then the current time, each read the same way. Ramp's accounting_date is not used: it follows the Ramp account's accounting preferences and can be overridden by hand. The cleared webhook, the nightly settlement reconciliation and the sandbox's POST /credit/sandbox/card-transactions all date a transaction this way, so the webhook and the reconciliation agree on the same transaction. A settlement whose period has already closed is still placed on the open cycle. When you push settlements yourself, send settled_on as the posting date at the issuer.

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 a void takes to zero reads voided; one a merchant win takes to zero with nothing paid on it reads reversed (with payments on it, paid). Neither has a paid_on, and hold_outcome says 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, voided, reversed
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
voided The issuer voided the transaction: the loan was taken off as if it never cleared, nothing is owed and nothing was collected for it. Anything paid towards it went to the credit balance. No paid_on
reversed A dispute the merchant won took the loan to zero before anything was paid on it. No paid_on

voided and reversed loans are left out of the portfolio's open book, the repayment measures and (for voided) the originations behind the default rate. They cannot be written off (CREDIT_531); a voided loan cannot be disputed.

GET /credit/loans/{loan_id} returns one loan with the same fields as a row of the list.

POST /credit/loans/{loan_id}/write-off charges a loan off. It is an operator action (Operator actions). The body is optional: written_off_on (default today; for an imported loan not before its import date: CREDIT_652), amount_cents and memo (up to 500 characters). Without amount_cents, or with one at or above the whole outstanding amount, the loan is written off in full: principal, fee and interest outstanding go to zero and its status is written_off. A smaller amount_cents is a partial write-off of principal only; the loan stays open and keeps accruing on what remains. The response is loan_id, written_off_on, principal_cents, fee_cents and interest_cents written off, total_cents, full, the loan's status and remaining_outstanding_cents. It emits credit.loan.written_off, for a partial write-off too (full: false). Refused with 400 when the loan is already written off (CREDIT_530), paid (CREDIT_531), on hold (CREDIT_207), the amount is not positive (CREDIT_532) or is more than the outstanding principal (CREDIT_533).

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. Cut-over. A line's cut-over is the earliest import date among its loans that still owed something as imported. Before it the previous system was the book: a payment that landed then is already in the imported loans' paid amounts, so a payment dated before the cut-over is 400 CREDIT_751 with nothing written (and a pending payment settling before it stays pending), whether or not it names a loan. A payment that landed after the cut-over is dated when it landed and pays the imported loans, oldest first, like any other. So import as at the day after the previous system's figures are good through (accrued_through the last day those figures cover, as_of the day after; the console's As of field), and record on DayZero only payments that landed on or after as_of. A loan imported later onto a line already on DayZero (a late transaction) does not move the cut-over: a payment dated between the two imports passes that later loan by, which was not on DayZero's books yet, and is applied to the loans that were. Each loan carries imported_on (null for a loan made on DayZero) and the source_system the import named.

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.

Two lending models, different initiation behavior. The pending-then-settlement flow below applies to per_transaction. A cycle_draw program rejects pending: true before writing anything; only report a cycle-draw payment after the funds have landed. Do not use it to record an ACH initiation.

Payment identity and retries. Creation deduplicates by credit line plus external_payment_id; a repeated request returns the existing payment without changing its amount or other fields. Settlement with the same date is repeatable; another date is refused. A repeated reversal is refused as already reversed. Settling a pending payment replaces its paid_on with settled_on, so keep the original initiation date with the processor evidence.

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 per payment; the program decides it (loan_ordering and allocation_order, see Program parameters). With the defaults 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 by default, a loan cannot then 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.

Payliance reporting integration

Connect Payliance on the lender business under Integrations → Payliance, or use POST /api/v1/payliance/connect with api_key, environment (production, staging or sandbox) and sync_from. The key is encrypted at rest. Payliance must allow the outbound IP addresses of both the API server and the sync worker. Connection verifies all three reporting feeds; the UI then starts an import. API callers start it separately with POST /api/v1/payliance/sync.

Payliance routes are outside /credit: a credit-only API token cannot access them. Writes require ADMIN permission on the lender business. GET /api/v1/payliance/status exposes health and checkpoints, never the key. DELETE /api/v1/payliance/disconnect removes credentials and stops scheduled imports while keeping evidence and matches.

For per-transaction repayments, record Payliance acceptance as:

json
{
  "credit_line_id": "LINE_ID",
  "external_payment_id": "THE_PAYLIANCE_UNIQUE_TRAN_ID",
  "amount_cents": 15000,
  "paid_on": "2026-10-30",
  "method": "ach_payliance",
  "pending": true
}

The amount is $150.00. Use the actual event date and retain the returned payment id. The connector automatically links a unique same-business reference with an exact positive debit amount and a Payliance method. Use references unique across the lender business, even though payment creation itself deduplicates per line. An operator can explicitly link an existing repayment when references differ.

Transactions, settlements and returns are imported separately, with every provider page followed. Sync runs every six hours through the previous day and re-reads two recent days. A settlement invokes the existing credit settlement path; a return invokes reversal. Notifications of change never reverse money. Partial returns, unknown directions, ambiguous references and incomplete evidence remain for review. Staging and sandbox evidence never changes live loan balances.

Bank reconciliation. Select settled items or late returns in the integration and find an exact signed bank total within five days of every selected event. A confirmed match attaches evidence to the existing bank row. If the linked repayments have posted loan journals using one separate cash/clearing account, an uncategorized bank offset can be moved to that account. Existing categorization, closed periods, missing journals, mixed clearing accounts and net-of-fee deposits require review. Check the accounting result separately from the evidence-link result.

Settlement authority. Production Payliance onboarding establishes the processor as the ACH settlement authority, including after its key is disconnected. Bank observations can attach evidence but cannot independently create ACH repayments or settle pending Payliance debits. Historical unreconciled bank repayments block automatic processor allocation until an operator chooses the canonical payment or confirms that a pending repayment is distinct. Matching does not attach historical money by amount alone. A confirmed bank decision returns effect (evidence_attached, settled, returned, or null), so the UI can distinguish reconciliation from another financial allocation.

Existing duplicates. The authority rules prevent new automatic duplicate credit; they do not silently unwind historical double-allocated payments. Review and correct those explicitly. If an old bank payment becomes canonical while another pending row already exists, the duplicate pending row stays nonallocating and must be cancelled explicitly.

A genuinely distinct landed debit. For either lending model, including cycle-draw, an administrator can reconcile a separate payment without creating an unsupported pending payment. Fetch GET /api/v1/payliance/transactions/{id}/payment-candidates?credit_line_id={line_id} and review every row in unreconciled_bank_payments. Then send POST /api/v1/payliance/transactions/{id}/match-payment with {"distinct_payment":{"credit_line_id":"…","reviewed_bank_payment_ids":["…"],"note":"Why this is a separate landed debit"}}. The existing { "payment_id": "…" } mode instead selects the canonical existing repayment; the modes cannot be combined. Distinct creation requires positive settled production debit evidence without a return. Amount, reference and date come from that evidence. Missing or stale acknowledgments return 409: refresh the list and review it again. The repayment and its canonical evidence link are recorded atomically.

review_before_apply also holds matching ACH returns. Explicit confirmation can reverse an applied repayment once; attaching another observation to an already-reversed repayment only adds evidence.

GET /api/v1/payliance/transactions?reference=... searches all imported payment references and authorization IDs before cursor pagination. Credit-workspace links can therefore locate older evidence directly instead of searching only the currently loaded page.

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.

Payment aliases. Record the sender name a merchant's money arrives under with POST /credit/lines/{id}/payment-aliases (alias, up to 255 characters, as the sender name, reference or account mask reaches your account; optional alias_type: sender_name (the default), account_mask, reference or other; source up to 64 characters; note up to 255). Matching folds case, drops punctuation and strips company suffixes, so "NORTHWIND LENDING, INC." and "Northwind Lending Inc" are one alias. Sending an alias the line already has returns it; an alias that already points at another line is refused (CREDIT_537), and one that is empty once cleaned is CREDIT_536. GET /credit/lines/{id}/payment-aliases lists a line's aliases and DELETE /credit/payment-aliases/{id} removes one. GET /credit/payments/resolve?description=... takes the text an incoming payment arrives with and returns credit_line_id (set only on an exact match, with matched_on: "alias"), normalised, and candidates for partial overlaps, which are for a person to confirm and are never treated as a match. It records nothing: take the line id and post the payment as usual.

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, a sync or a Shopify pull has finished in full and no history import is still running (orders arriving are not enough: the import writes them for hours before it ends). It also checks that the data is current: a Plaid connection the merchant must log in to again, or a bank feed failing for a reason you must fix (not_shared, not_found, auth_failed, not_configured), waits on bank_reconnect and emits credit.bank_connection.reconnect_required; a bank source that has not synced, or whose newest balance is not recent, waits on bank_data_stale; a store not pulled in the last 3 days waits on shopify_data_stale. These are the same tests the measures apply (see Underwriting measurers). 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, bank_reconnect, bank_data_stale, categorisation, shopify_sync, shopify_data_stale, shopify_disputes, meta_ads_sync (a scorecard with an ad-spend rule, while a connected Meta Ads account has not finished its first sync): 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. Its optional body takes as_of, segment (for per-segment thresholds, see Criteria) and fail_if_unmeasurable (true marks the assessment failed when no criterion could be measured). 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. unmeasured_keys lists the measure keys that came back unmeasured and required_unmeasured the required ones among them: score_bps leaves unmeasured criteria out (under the default exclude_from_denominator policy), so a score of 10000 with most criteria unmeasured rests on very little. decision is null until the lender records one (see Recording the decision).

Send auto_approve: true (with external_merchant_id, and optionally merchant_legal_name and merchant_email) and DayZero opens the merchant's line itself when the completed assessment allows it, and records the approval (decided_via: auto). A token opens lines only on an active program, as POST /credit/lines does.

Who asked. auto_approve sent on the request, true or false, is what applies. A request that omits it takes the program's auto_approve_default (see Program parameters): true and the assessment auto-approves as if the request had asked; false (the default) and the lender records the decision. On a sandbox a partner application (an external_merchant_id, or an external_application_id starting tie:) auto-approves even when neither asked. The assessment's auto_approval.source says which applied: request, program_default or sandbox_implicit; it is set from the moment the assessment is requested, so a pending assessment already says whether a line will open on its own. An integration that never sends auto_approve is switched to automatic approval by turning the program's default on, with no change on its side.

The gate. On a live business a line opens when:

  • no criterion failed (any failure, required or not, opens nothing);
  • every criterion that is required for the application was measured. A criterion that is not required and could not be measured does not hold the line: a merchant with no Shopify store, no scheduled debt (debt_service_coverage_ratio reads no_debt_service) or no invoices is judged on what it does have;
  • at least one criterion passed;
  • every required manual check that applies was attested (see the hold below).

On the sandbox only failures (of a criterion, or of a required manual check) stop it, so a synthetic merchant with no history can rehearse the whole flow. A sandbox application with an external_merchant_id (or an external_application_id starting tie:) auto-approves even when auto_approve is not sent; send auto_approve: false to hold a sandbox application for a lender decision, e.g. to rehearse a decline.

The limit. When the scorecard sizes limits (scoring.limit_sizing, below) the line opens at the lower of the requested limit and the sized one; with no requested limit, at the sized one (and the rules written as a multiple of the limit are scored at it). Either way it is held to the program's maximum. A request below the program's minimum opens nothing rather than lending more than was asked (requested_limit_below_program_minimum), and neither does a sized limit below it (sized_limit_below_program_minimum). On a live business a scorecard that sizes limits and found nothing to size from opens nothing (limit_sizing_unmeasured); a sandbox falls back to the requested limit. The term is the requested one, else the program's first.

An oversized request. A merchant who applies for more than its measures support fails the rules written against the limit (× requested limit, or a required or applies_when condition on it) at the limit applied for, while every other rule reads the same figure whatever the limit. When only such rules failed and the sized limit is lower than the requested one, DayZero scores the application again at the sized limit, the limit the line would open at anyway. If nothing fails there, that second evaluation is the recorded result: criteria (each limit rule's threshold resolved at the sized limit), the counts, score_bps and grade are its, and the assessment carries limit_rescored (requested_cents, sized_cents, reason: rescored_at_sized_limit), as do auto_approval, credit.underwriting.completed and credit.underwriting.decided. The first evaluation is kept on the record (financials._evaluation_at_requested: its summary and criteria at the requested limit), so what was applied for and refused can always be read. If something still fails at the sized limit, or a rule that reads no limit failed, or the scorecard does not size limits, the result at the requested limit stands as it always did and limit_rescored is null. The gate then runs on the recorded result, so an application re-scored this way opens at the sized limit (limit_source: sized) with no decline recorded for the larger request. This is deterministic: the same figures and the same scorecard always give the same result.

The rate. A line opened from an assessment, automatically or by an approval that names no approved_rate_bps, takes the scorecard's rate for the assessment's grade (scoring.rate_by_grade), else the program's default_rate_bps, else 1800. The assessment's pricing says which (rate_bps, rate_source: grade, program_default, platform_default, request or existing_line, and grade).

The manual-check hold. A manual check marked required on the scorecard holds the line while it is pending: auto_approval.status is held with manual_checks_pending. When a reviewer attests the last required check (POST /credit/underwriting/assessments/{id}/manual-checks/{check_id}, status: attested) DayZero runs the same gate again on the figures the completion recorded and, if it passes, opens the line and emits credit.line.opened and credit.underwriting.decided (decided_via: auto). A required check attested failed opens nothing (manual_check_failed); one with an exception noted also opens nothing (manual_check_exception). Neither is a final decline or queues an adverse-action notice.

What happened is on the assessment as auto_approval and in the credit.underwriting.completed event:

Field Meaning
status pending (not completed yet), line_opened, existing_line (a line under the same external_line_id was already open: it is linked, not changed), held (only required manual checks stand in the way), not_eligible or failed (DayZero tried and the line was refused; detail says why)
reasons Why no line opened: line_already_linked, requested_limit_missing, criteria_failed, required_failed, required_unmeasured, no_criteria_passed, requested_limit_below_program_minimum, sized_limit_below_program_minimum, limit_sizing_unmeasured, manual_checks_pending, manual_check_failed, manual_check_exception, assessment_failed, line_open_refused. Assessments completed before 6 November 2026 may also carry criteria_unmeasured, from when any unmeasured criterion held a live line
credit_line_id, credit_limit_cents, term_days The line and the terms it opened with, beside requested_limit_cents and requested_term_days
sized_limit_cents, limit_source The sized limit, and what set credit_limit_cents: requested, sized or program_max
rate_bps, rate_source, grade The rate the line opened at and where it came from
source Who asked for automatic approval: request, program_default or sandbox_implicit (above)
limit_rescored Set when the application was scored again at the sized limit (above): requested_cents, sized_cents, reason: rescored_at_sized_limit. Null otherwise
sandbox, decided_at Which rule applied, and when

credit.underwriting.completed carries status (completed or failed), the counts, unmeasured_keys, required_failed, required_unmeasured, weighted_score_bps, grade, segment, auto_approval (null unless the request, the program's default or the sandbox asked for it), limit_sizing (null without sizing) and limit_rescored (null unless re-scored); a line opened this way also emits credit.line.opened and credit.underwriting.decided, so one field answers for every outcome.

Limit sizing and risk-based pricing

In the console, edit a draft scorecard version's Limit sizing and pricing section: add basis measures in priority order, their annualising multipliers, the percentage of that annualised figure to lend, optional caps, and the rounding increment (entered in dollars). Rate by grade takes percentages for the grade bands defined under Scoring; blank uses the program default. Saving does not change the active policy: validate, approve and activate the version as usual. No sizing or rate policy is added until you choose it.

Both live on the scorecard's scoring object, so they are versioned, validated and approved with the rest of it:

json
{
  "method": "weighted_pass_ratio",
  "unmeasured_policy": "count_as_zero",
  "limit_sizing": {
    "basis": [
      {"measure": "trailing_3m_revenue", "multiplier": 4},
      {"measure": "trailing_12m_revenue", "multiplier": 1},
      {"measure": "shopify_trailing_12m_revenue", "multiplier": 1}
    ],
    "percent_of_annual_bps": 200,
    "caps": [
      {"measure": "average_daily_cash_balance", "multiple": 2},
      {"measure": "trailing_12m_ad_spend", "multiple": 0.0626}
    ],
    "round_down_to_cents": 10000
  },
  "rate_by_grade": {"A": 1500, "B": 1800, "C": 2100, "D": 2400}
}

The sized limit is the first basis measure that was measured above zero, times its multiplier (to a year), times percent_of_annual_bps; then held under each caps entry that was measured (value × multiple; an unmeasured cap is skipped and reported) and rounded down to round_down_to_cents. The assessment's limit_sizing shows every input: basis_measure, basis_value, basis_multiplier, annual_basis, basis_limit_cents, each cap with its cap_cents, binding (the measure that set the figure) and sized_limit_cents (null, with reason: sizing_basis_unmeasured, when no basis measure had a value). The validator refuses a sizing measure DayZero cannot measure or that is not a currency, and warns about a rate_by_grade label no grade band uses, or a band with no rate. A scorecard without limit_sizing lends the requested limit, as before; one without rate_by_grade prices at the program's default.

The figures in the standard seed follow from its own limit rules: §10 asks for three months of revenue of at least 12 times the limit, which allows about 2.08% of annualised revenue, so the seed sizes at 2% of the annualised three-month figure (roughly a week of sales); §7 asks for average daily cash of at least half the limit, so cash caps it at twice the average; the ad-spend rule (twelve months of ad spend at least 15.96 times the limit) caps it at 0.0626 times that spend. A limit sized this way passes the limit rules it was sized from. The seed scores count_as_zero, so a merchant whose debt service or customer concentration could not be measured grades lower than one whose could; with every applicable rule passed, a bank-only merchant scores about 84% (grade B, 18%), and one that also has Shopify data, from which its customer concentration can be read, about 91% (grade A, 15%).

Which rules apply to whom

applies_when and required can read shopify_data (1 when DayZero holds Shopify data for the merchant: a connected store or pulled orders; 0 otherwise). The standard seed's refund and chargeback rules apply only when it is 1, and the chargeback rule is required only then: a merchant with only bank data is not held for Shopify figures it cannot have. debt_service_coverage_ratio and revenue_concentration_top_customer are scored but not required, so their absence never holds a line.

Recording the decision

The scorecard is evidence; the decision is the lender's. Record it on a completed assessment with POST /credit/underwriting/assessments/{id}/decision, from a token with the credit scope (sandbox and live) or in the console, where a live business asks for the same two-factor check as other sensitive credit writes:

json
{
  "decision": "approved",
  "merchant_message": "Welcome aboard.",
  "note": "Credit committee, 3 Nov",
  "approved_limit_cents": 20000000,
  "approved_term_days": 60,
  "open_line": true
}

decision is approved or declined. merchant_message (up to 1000 characters) is shown to the merchant as written. note (up to 2000) is internal: it appears in the console only, never in a token's response or an event. On an approval approved_limit_cents and approved_term_days default to the requested ones (the limit to the sized one when that is lower) and must fit the program (CREDIT_512, CREDIT_513); they are refused on a decline (CREDIT_554). approved_rate_bps sets the rate of a line the approval opens; without it the line is priced as above (grade, then program default). With open_line (the default) DayZero opens the line the way auto-approval does, or links the one already open for the merchant; open_line: false records the approval and opens nothing. If the line cannot be opened nothing is recorded and the error is returned. A decline queues the adverse-action notice when the program's compliance policy has them on.

One decision per assessment. Repeating the same one returns the record (200); a different one is 409 CREDIT_553; a pending or failed assessment is 409 CREDIT_552. Reversing a decision is not supported. The assessment's decision then reads:

Field Meaning
status approved or declined
merchant_message The message for the merchant, or null
approved_limit_cents, approved_term_days The approved terms; null on a decline
credit_line_id The line opened or linked; null on a decline or when open_line was false
decided_at, decided_via When, and how: console, api_token or auto

The decision is emitted once as credit.underwriting.decided.

Decision requirements (optional). Operators can configure underwriting_decision_policy on the program. max_evidence_age_days defaults to null (no expiry); when set, approvals require an assessment requested within that many days. An expired explicit approval returns 409 with reason: evidence_expired; automatic approval reports the same gate reason and opens nothing. Request a new assessment rather than changing the completed evidence. No expiry interval is supplied by DayZero.

decline_reason_catalog maps lender-approved codes to merchant-facing sentences. Send decline_reason_codes on the final decline to select its principal reasons. The selected code and sentence are frozen as decision.decline_reasons and included in the decided event and notice. Unknown codes and reasons on approvals are refused. require_decline_reasons defaults to false; enabling it requires at least one selection. Failed criteria remain assessment evidence and are never automatically asserted as the lender's principal reasons. A decline without selected reasons can still be recorded under the default policy, but its notice cannot be sent.

Nonfinal updates. Subscribe to credit.underwriting.updated for data-required and manual-check/gate transitions. It carries assessment_id, external_application_id, monotonically increasing version, assessment status, waiting_on, auto_approval_status, gate_reasons, and manual-check ids/statuses. Unchanged rechecks emit nothing; returning to an earlier state creates a new version. Internal notes and recipient identity are excluded. Completion and final-decision events retain their separate, once-per-assessment meaning.

Application-only notices. The existing notices-due Send action can deliver a final decline before any line exists. It uses the application's recorded merchant_email, merchant_legal_name, merchant_state and merchant_address, the program's enabled adverse-action policy, and an approved template. Delivery uses the existing transaction-staged, deduplicated borrower-notice pipeline. No automatic send is enabled. Notices whose source is not a final decline, or whose reasons do not match the decision, are blocked for operator review.

For historical reconciliation, operators can run uv run python -m scripts.admin.report_credit_decline_notices --business-id <uuid>. This read-only report lists missing final declines and missing/mismatched decision reasons, including sent records. It neither rewrites decisions nor infers reasons. Review pending records through the existing reason-required waiver workflow; sent records require operator review.

Send what is being applied for with the request:

plaintext
POST /credit/underwriting/assessments
{ "program_id": "...", "external_application_id": "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 (measured_values, a map of criterion key to figure, is required; financials, evidence, segment and manual_checks are optional), 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, and the program the application is under in program_id (the line's, or the one the request named; null on an ad-hoc assessment with neither).

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. GET /credit/underwriting/assessments/{id}/adverse-action adds explanatory sentences. These are assessment evidence for the reviewer, not the final decision's principal reasons. Notices use only the explicitly selected decision.decline_reasons.

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 record a decision.

The merchant's bank connection

The assessment reads the merchant's bank feed, which lives on the merchant's own DayZero business. Start the connection from your Apply wizard's Connect step: DayZero finds the merchant's business, or creates it the first time, and hands you a hosted Plaid Link for the merchant to complete inside your wizard. DayZero emails nothing.

plaintext
POST /credit/bank-connections
{ "program_id": "...", "external_merchant_id": "tie_merchant_00417",
  "merchant_email": "ap@merchant.com", "merchant_legal_name": "Northwind Goods LLC",
  "completion_redirect_uri": "https://apply.example.com/connect/done" }
-> { "id": "...", "status": "pending", "hosted_link_url": "https://hosted.plaid.com/...",
     "expires_at": "...", "merchant_business_id": "...", "merchant_business_created": true }
Field Meaning
program_id Your program the merchant is applying under. Required without credit_line_id; with it, must be the line's program if sent
external_merchant_id Your id for the merchant. Required without credit_line_id. Every start for the same merchant on your business uses the same DayZero business
credit_line_id An open line of yours, for Change Payment Method: the account the merchant connects is recorded on it. A program_id or external_merchant_id that is not the line's is 400 CREDIT_781; a closed line is 400 CREDIT_784
merchant_email The merchant's email, which Plaid asks for with the Link. Defaults to the line's merchant_email; required when neither is there (400 CREDIT_785). Not used to email anyone
merchant_legal_name Names the business when this call creates it. Defaults to the line's merchant_legal_name, else Merchant <external_merchant_id>
merchant_business_id The merchant's existing DayZero business, when it already keeps its books here and your token can read it (403 otherwise). Naming a different business than the one the merchant is linked to is 400 CREDIT_788
completion_redirect_uri Where Plaid sends the merchant when Link finishes. It must be one of the program's bank_link_redirect_uris exactly: https, no query or fragment (400 CREDIT_782 otherwise). You set that list yourself on the program form, field Bank Link return URLs (or bank_link_redirect_uris on POST/PATCH /credit/programs): https URLs; on the sandbox http://localhost and http://127.0.0.1 (any port and path) are allowed too, and refused on a live program (400 CREDIT_789). Plaid sends the merchant straight to this URL; it needs no registering in the Plaid dashboard. Without it Plaid shows its own finished page
reconnect true to repair the merchant's existing bank connection rather than link a new bank: the Link is Plaid's update mode on the connection that needs the merchant to log in again (what waiting_on: bank_reconnect and credit.bank_connection.reconnect_required report). The merchant's business must exist and have such a connection (400 CREDIT_779 otherwise). The response carries reconnect: true; completion is credit.bank_connection.completed as for a new bank, and the repaired connection syncs again on its own

The response is the connection: id, status (pending, completed, failed or expired), created (false when a connection already pending for the merchant, or the line, was returned instead of a new one), hosted_link_url (null once the connection is no longer pending), expires_at (four hours), merchant_business_id, merchant_business_name and merchant_business_created. Send merchant_business_id as the assessment's subject_business_id. The business is linked to your business from then on, and the person who started the connection (the token's owner, for a token) is given read access to it, a viewer membership, so the token reaches it as a subject: it is listed by GET /credit/underwriting/subject-businesses and accepted as subject_business_id and merchant_business_id. A token limited to your business reaches it too, but never as x-business-id. Another person's token reaches it once they start a connection for the merchant. If Plaid will not make the Link the call is 503 CREDIT_787 and nothing is kept.

When the merchant finishes, the connection lands on the merchant's business like any bank connection, its accounts are linked, and the bank backfill the assessment waits on (waiting_on: bank_connection, then bank_backfill) starts. The connection becomes completed with institution_name and accounts (each plaid_account_id, name, mask, type, subtype; never a number or token), and plaid_account_id names the repayment account: the one checking account the merchant shared, else the one depository account, else none. On a line it is recorded there (plaid_account_id, linked_account_mask, linked_account_name) and recorded_on_line is true. credit.bank_connection.completed is emitted with the same fields. A merchant who leaves Link fails the connection (failure_reason: exited; plaid_error when Plaid could not connect, no_accounts when nothing was shared) and credit.bank_connection.failed is emitted. A Link not used before expires_at reads expired and emits nothing. After a failure or expiry, start again: the new Link is on the same business.

Read the connection with GET /credit/bank-connections/{id} if your wizard polls it rather than the feed. When the merchant has finished and Plaid's notice has not reached DayZero, the read asks Plaid itself. A connection on another business is 404 CREDIT_783.

A line opened later with the same external_merchant_id is linked to the merchant's business (unless the request names another merchant_business_id), and takes the repayment account when the request sends no plaid_account_id, linked_account_mask or linked_account_name. A connection the merchant has yet to finish then records its account on that line.

Change Payment Method. GET /credit/lines/{id}/linked-accounts lists every bank account the line's merchant has linked on DayZero: plaid_account_id, name, mask, type, subtype, institution_name, connection_status (the connection's state at DayZero, requires_reauth when it needs the merchant again) and on_line (the account on the line now), with the line's merchant_business_id and plaid_account_id. A line linked to no business lists nothing. Record another account with PATCH /credit/lines/{id}; connect a new bank with POST /credit/bank-connections and credit_line_id.

Code Meaning
CREDIT_780 Neither credit_line_id nor program_id with external_merchant_id was sent
CREDIT_781 program_id or external_merchant_id is not the line's, or the line is on no program
CREDIT_782 completion_redirect_uri is not one of the program's bank_link_redirect_uris
CREDIT_783 (404) No such bank connection on this business
CREDIT_784 The line is closed
CREDIT_785 No merchant_email, and the line has none
CREDIT_786 The sandbox completion names a connection that is no longer pending
CREDIT_787 (503) Plaid did not create the Link; nothing was started
CREDIT_788 merchant_business_id is not the business the merchant is linked to
CREDIT_789 A live program's bank_link_redirect_uris names an http://localhost URL; those are for the sandbox
CREDIT_779 reconnect: true, and the merchant has no business yet or no bank connection that needs reconnecting

Sending the merchant's data

A merchant you bring through the credit API has a DayZero business that holds only what you send. Every measure on the scorecard reads one of the sources below; send each one you hold, and the measures that read it are measured instead of passed: null. Every call names the merchant the same way (program_id with external_merchant_id, or credit_line_id; merchant_legal_name and merchant_business_id as for a bank connection, with the same CREDIT_780, CREDIT_781, CREDIT_784 and CREDIT_788 refusals), creates the merchant's business the first time, gives your token read access to it, and is safe to repeat. They all land on one business per merchant, whichever comes first. A business created this way gets DayZero's standard chart of accounts, so its bank rows are categorised to sales, transfers, loans and expenses rather than left in Uncategorized.

Source Call Measures that read it
Bank, you push it (recommended) POST /credit/bank-feeds with provider: direct_push, then POST /credit/bank-feeds/{id}/ingest (below) trailing_12m_revenue, trailing_3m_revenue, average_daily_cash_balance, nsf_count_12m, cash_runway_months, the ledger halves of the ratios
Bank, you hold it in BigQuery POST /credit/bank-feeds (below) as above
Bank, merchant connects POST /credit/bank-connections (hosted Plaid Link, above) as above
Shopify POST /credit/shopify-pull shopify_trailing_12m_revenue, shopify_trailing_90d_sales, shopify_refund_rate, shopify_chargeback_rate, shopify_revenue_growth_yoy, marketing_efficiency_ratio_90d, revenue_concentration_top_customer (without invoices)
Profile PUT /credit/merchant-profile months_in_business
Existing debt PUT /credit/debt-facilities existing_debt_facilities, debt_service_coverage_ratio
Ad spend POST /credit/ad-spend trailing_12m_ad_spend, ad_spend_to_revenue_ratio, ad_spend_to_revenue_ratio_90d, marketing_efficiency_ratio_90d
Invoices POST /credit/receivables revenue_concentration_top_customer

Only the bank is required: the assessment waits for it (waiting_on). The rest never hold an assessment; a source you do not send leaves its measures unmeasured. GET /credit/merchant-data (the merchant by merchant_business_id, credit_line_id, or program_id with external_merchant_id) lists every source with status (provided, not_provided or stale), required, last_received_at, items, the measures it feeds and, when it is missing or old, what to send (detail); a merchant you have no business for is 404 CREDIT_836. The same states are on the assessment's readiness record as financials._readiness.detail.sources.

Pushing the bank yourself

The recommended way to bring a merchant's bank: you already hold the accounts, transactions and balances in your own backend, so you push them and nothing else is read. Register a feed with "provider": "direct_push" and your own key for the merchant as app_id (letters, digits and underscores; nothing is read from BigQuery), then push, as often as you like:

plaintext
POST /credit/bank-feeds
{ "provider": "direct_push", "app_id": "tie_merchant_00417", "program_id": "...",
  "external_merchant_id": "tie_merchant_00417", "merchant_legal_name": "Northwind Goods LLC" }
-> { "id": "...", "status": "pending", "created": true, "provider": "direct_push",
     "merchant_business_id": "...", "merchant_business_created": true, "accounts": [] }

POST /credit/bank-feeds/{id}/ingest
{ "accounts": [{ "account_id": "acct_1", "institution_name": "First Bank", "display_name": "Operating",
                 "last4": "4242", "type": "cash" }],
  "transactions": [
    { "transaction_id": "txn_1", "account_id": "acct_1", "amount_cents": 1500000, "currency": "USD",
      "description": "SHOPIFY PAYOUT", "status": "posted", "transacted_on": "2026-10-30",
      "category": "INCOME_OTHER_INCOME", "category_source": "stripe_fc" },
    { "transaction_id": "txn_2", "account_id": "acct_1", "amount_cents": -3500, "currency": "USD",
      "description": "SERVICE CHARGE", "status": "posted", "transacted_on": "2026-11-02",
      "category": "BANK_FEES_OVERDRAFT_FEES", "category_source": "stripe_fc" },
    { "transaction_id": "txn_0", "account_id": "acct_1", "status": "void", "amount_cents": 0,
      "description": "reversed", "transacted_on": "2026-10-29" } ],
  "balances": [{ "account_id": "acct_1", "as_of": "2026-11-05", "current_balance_cents": 4250000, "currency": "USD" }] }
-> { "inserted": 2, "updated": 0, "voided": 1, "pending_skipped": 0, "balance_days": 7,
     "accounts_used": 1, "accounts_skipped": 0, "feed": { ... } }
Field Meaning
transaction_id Your id for the transaction, the idempotency key: pushing it again updates the row when its amount or date changed and leaves it alone otherwise, so a full re-push is safe. status: void hides one posted before; pending is skipped until it posts
amount_cents Signed as cash moves: money into the account positive, money leaving negative
currency Three-letter ISO code, default USD. Underwriting reads money in the merchant business's own currency (set it with PUT /credit/merchant-profile while the books are empty); rows and balances in another currency are kept and left out of the measures, never converted
category, category_source Optional. The transaction's category as a Plaid personal-finance detailed code (BANK_FEES_OVERDRAFT_FEES, BANK_FEES_INSUFFICIENT_FUNDS, INCOME_OTHER_INCOME, TRANSFER_IN_ACCOUNT_TRANSFER, …), recorded on the row where Plaid's own code is, so the measures that read Plaid's read yours the same way: nsf_count_12m counts the two bank-fee codes whatever the description says, and the categoriser takes the code as a hint. Any case is accepted and stored upper-cased. category_source says where it came from (plaid, stripe_fc, partner) and is kept beside it. A category pushed later for a row already booked is recorded without rebooking it. Without one, the description is read
balances Each is that day's closing balance for the account (as_of, current_balance_cents, currency default USD); the days between reported ones are worked out from the nearest balance and the day's transactions, so average_daily_cash_balance has a figure for every day. Idempotent on account and day
accounts type: cash accounts are booked on a bank ledger each; a credit account is not cash and is left out (skipped_reason: credit_account), as is one the merchant also connected through Plaid (covered_by_plaid)

Up to 100 accounts, 5,000 transactions and 1,000 balances a call: send a long history in pages, oldest first. Send at least a year of history and the twelve-month measures stop reading insufficient_history; a bank-only merchant with less is scored on an annualised figure only where the criterion opts in (allow_annualized). Each posted row is booked on the account's bank ledger with its journal entry, and after a push that changed anything the vendor naming, transfer detection, payout matching and categorisation a Plaid feed gets run on the merchant's business. Underwriting treats the feed as the merchant's bank: the assessment no longer waits on bank_connection, bank_backfill clears with the first push (first_synced_at), and the feed reads active.

Freshness. The bank measures read what you push for three days: while the feed's newest transaction or balance is more than 3 days older than the day measured (a balance may trail by 4), the assessment waits on bank_data_stale and the bank measures (average_daily_cash_balance, cash_runway_months, nsf_count_12m, shadow_debt_and_mca_90d) come back unmeasured with bank_data_stale and the source in evidence.bank_sources. So push at least daily while a merchant is being assessed, and push its closing balance each day. GET /credit/bank-feeds/{id} shows latest_transaction_on and latest_balance_on; GET /credit/merchant-data reports the bank as stale with what to send.

The merchant's bank feed from your BigQuery

When you already hold the merchant's bank through Stripe Financial Connections and copy it into BigQuery (one dataset per organisation, ab_stripe_fc_{app_id}, with the accounts, transactions_current and balances tables), DayZero can read it there instead of asking the merchant for a Plaid Link. Grant DayZero's service account READER on each dataset (we send you its email; never a project-level role), then register the merchant's feed:

plaintext
POST /credit/bank-feeds
{ "app_id": "org_abc123", "program_id": "...", "external_merchant_id": "tie_merchant_00417",
  "merchant_legal_name": "Northwind Goods LLC" }
-> { "id": "...", "status": "pending", "created": true, "dataset": "<project>.ab_stripe_fc_org_abc123",
     "merchant_business_id": "...", "merchant_business_created": true, "accounts": [] }

app_id is the organisation id the dataset is named after (letters, digits and underscores; 400 CREDIT_831 otherwise). The merchant is named as for a bank connection (program_id with external_merchant_id, or credit_line_id; the CREDIT_780, CREDIT_781, CREDIT_784 and CREDIT_788 refusals above apply), and it is the same business: a feed and a bank connection for one merchant land on one DayZero business, a line opened later for the merchant is linked to it, and the caller is given read access to it as a subject. Send merchant_business_id from the response as the assessment's subject_business_id. Registering the same app_id again returns the feed (created: false), and resumes it if it was removed; for another merchant it is 409 CREDIT_832, with the feed's bank_feed_id and merchant_business_id.

DayZero syncs a feed when it is registered and every six hours after. Each sync reads what arrived since the last one and writes it to the merchant's business: every posted transaction (amounts as Stripe gives them, money in positive) on a bank ledger per account with a journal entry against Uncategorized Income or Expense, then the same vendor naming, transfer detection, payout matching and categorisation a Plaid feed gets; a voided transaction is hidden; a pending one is left until it posts. Each reported balance is that day's closing balance, and every other day back to the first transaction is worked out from the nearest reported balance and the day's transactions, so the average daily balance has a figure for each day. An account the merchant also connected through Plaid (same last four at the same bank) is left out, or its money would be counted twice, and so is a credit account (skipped_reason: covered_by_plaid, credit_account). Stripe returns up to 180 days of history, so rules over twelve months read insufficient_history from the bank alone, unless the criterion opts into an annualised figure (allow_annualized, see Criteria). Balances keep their currency (the key of Stripe's balance hash; currency on a pushed balance, default USD), and underwriting reads only those in the merchant business's own currency.

Underwriting treats the feed as the merchant's bank: the assessment no longer waits on bank_connection, and bank_backfill clears when the first sync lands (first_synced_at).

Call What it does
GET /credit/bank-feeds/{id} Where the feed stands: status (pending, active, error, removed), first_synced_at, last_synced_at, latest_transaction_on, latest_balance_on, and accounts (account_id, institution_name, display_name, last4, status, transactions, first_transaction_on, last_transaction_on, balance_cents, balance_on, skipped_reason). While a sync cannot read the dataset the feed is error with last_error: not_shared (DayZero's service account has no READER on it), not_found (no dataset for this app_id), not_configured, auth_failed, bigquery_error or sync_failed, and last_error_detail; the next run tries again. Another business's feed is 404 CREDIT_830
GET /credit/bank-feeds Your feeds, newest first; filter with credit_line_id, external_merchant_id or merchant_business_id
POST /credit/bank-feeds/{id}/sync 202: sync now, e.g. right after granting access (queued: false when one is already running). A removed feed is 400 CREDIT_833
DELETE /credit/bank-feeds/{id} Stop syncing. What arrived stays on the merchant's business
Code Meaning
CREDIT_830 (404) No such bank feed on this business
CREDIT_831 app_id is not an organisation id DayZero can name a dataset from
CREDIT_832 (409) The app_id already feeds another merchant
CREDIT_833 The feed was removed; register it again to resume

The rest of the merchant's data

The sources that need no bank: each is pushed the same way, named for the same merchant, and read by the measures in the table above.

Shopify. For a merchant whose Shopify Admin API token you keep, POST /credit/shopify-pull reads its orders, refunds, disputes and Shopify Capital transactions onto the merchant's business without DayZero storing the token. Encrypt the token with the API token you call with as the key:

  1. salt: 16 to 32 random bytes, hex. iv: 12 random bytes, hex.
  2. key = PBKDF2-HMAC-SHA256(your API token, salt, 100,000 iterations, 32 bytes).
  3. AES-256-GCM-encrypt the Shopify token with key and iv, with the normalised shop_domain (<store>.myshopify.com, lowercase) as additional authenticated data. Send the ciphertext base64 as encrypted_token and the 16-byte tag hex as auth_tag.
  4. token_hash = hex SHA-256 of {salt}:{shop_domain}:{shopify token}.
plaintext
POST /credit/shopify-pull
{ "program_id": "...", "external_merchant_id": "tie_merchant_00417", "shop_domain": "northwind.myshopify.com",
  "encrypted_token": "...", "salt": "...", "iv": "...", "auth_tag": "...", "token_hash": "..." }
-> { "merchant_business_id": "...", "orders_synced": 1840, "refunds_synced": 41, "disputes_synced": 3,
     "capital_transactions_synced": 0, "orders_complete": true, "disputes_complete": true,
     "token_persisted": false, "synced_at": "..." }

The token needs read_orders (and read_shopify_payments_disputes for the chargeback rule). The Shopify measures read a pull for 3 days (shopify_data_stale after), so pull at least that often while a merchant is being assessed. orders_complete or disputes_complete false means part of the window was not read and the measures that need it stay unmeasured until a pull completes. A call without a Bearer credential is 400.

Profile. PUT /credit/merchant-profile takes business_started_on (not in the future), legal_entity_type (sole_proprietorship, partnership, llc, s_corp, c_corp, nonprofit, other), naics_code (2 to 6 digits), mcc (4 digits), industry, country (ISO alpha-2) and currency (ISO 4217). Fields left out keep what an earlier call set. months_in_business reads the start date first, with evidence.source: partner_profile and basis: declared; without one it falls back to the first Shopify order or bank transaction, basis: inferred, a lower bound. The currency becomes the merchant business's while its books are empty (currency_applied), and every measure reads money in it.

Existing debt. PUT /credit/debt-facilities replaces the merchant's facilities with the list you send:

plaintext
PUT /credit/debt-facilities
{ "program_id": "...", "external_merchant_id": "tie_merchant_00417",
  "facilities": [{ "facility_id": "mca_77", "lender": "QuickCap", "facility_type": "mca",
                   "current_balance_cents": 3000000, "payment_amount_cents": 25000,
                   "payment_frequency": "daily", "maturity_on": "2027-03-01" }] }
-> { "facilities": 1, "created": 1, "updated": 0, "removed": 0, "none_attested": false,
     "total_balance_cents": 3000000, "annual_debt_service_cents": 6300000, "declared_at": "..." }

Each facility has your facility_id (sent again it is updated, left out it is removed), lender, facility_type (term_loan, line_of_credit, mca, revenue_based, sba_loan, equipment_loan, mortgage, credit_card, other), current_balance_cents, and optionally original_amount_cents, payment_amount_cents with payment_frequency (daily counts 252 business days a year, weekly, biweekly, semi_monthly, monthly, quarterly, annual), interest_rate_bps, started_on and maturity_on. They go on the merchant's debt schedule, where existing_debt_facilities (the balances) and debt_service_coverage_ratio (earnings over a year's payments) read them; nothing is posted to its ledger. "facilities": [] attests the merchant has no debt: existing_debt_facilities reads 0 (none_attested_on), where with no schedule and no attestation it is unmeasured (no_debt_schedule), and debt_service_coverage_ratio reads 100 (evidence.capped; 0 when the business spends more than it earns), where it is otherwise unmeasured (no_debt_service). Both carry none_attested and partner_declared_at in their evidence.

Ad spend. POST /credit/ad-spend upserts daily spend:

plaintext
POST /credit/ad-spend
{ "program_id": "...", "external_merchant_id": "tie_merchant_00417",
  "covered_from": "2025-10-01", "covered_through": "2026-11-04",
  "days": [{ "platform": "meta", "spent_on": "2026-11-04", "spend_cents": 182000, "currency": "USD" },
           { "platform": "google", "spent_on": "2026-11-04", "spend_cents": 64000, "currency": "USD",
             "account_id": "123-456-7890" }] }
-> { "days_written": 2, "covered_from": "2025-10-01", "covered_through": "2026-11-04", "other_currency_days": 0 }

One row per platform (meta, google, tiktok, other), account_id (optional) and spent_on; sending one again replaces it. covered_from to covered_through (default: the earliest and latest day sent) is the window your figures are complete for: a day in it without a row is a day with no spend, so a window with no days attests none. Windows widen across pushes. The ad-spend measures read this spend before the Advertising & Marketing ledger and Meta Ads (evidence.source: partner_ad_spend, with partner_ad_spend_by_platform) while the window covers 90% of the measure's and covered_through is no more than 3 days before the day measured (partner_ad_spend_stale otherwise, and the measure falls back to the ledger or Meta). A measured window with no spend reads 0. Spend in another currency than the merchant's books is kept and not counted (partner_ad_spend_excluded_currency_days).

Invoices. POST /credit/receivables upserts the merchant's invoices to its customers by invoice_id: customer_id, customer_name, amount_cents, currency, issued_on, due_on, paid_on and status (open, paid, void; default paid with paid_on, else open). Up to 5,000 a call; invoices not sent are kept. When the merchant has no DayZero invoices, revenue_concentration_top_customer is the top customer's share of the invoices issued in the last 365 days in the books' currency, void ones left out (evidence.source: partner_receivables), before it falls back to Shopify orders.

Meta Ads. A merchant can also connect Meta Ads on its DayZero business, which the ad-spend measures read when no pushed spend covers the window. That connection is the merchant's own sign-in in DayZero; there is no hosted Meta link for a partner to hand out yet, so push the spend instead.

Code Meaning
CREDIT_836 (404) GET /credit/merchant-data names a merchant you have no business for

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 or Meta Ads spend, whichever is larger, 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 issuer-control recomputation is queued. applied describes the ledger decision; issuer delivery is tracked separately.

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

Covenants

A covenant is a promise the lender holds a line to, tested every night against the borrower's own books (the line's merchant_business_id): keep cash above a floor, leverage below a ceiling, debt service covered, inventory turning, current assets covering current liabilities, or any ratio of two ledger selections. Nothing a covenant finds changes the limit, the line or a card on its own.

POST /credit/lines/{id}/covenants writes one with kind (min_cash, max_leverage, min_dscr, min_inventory_turns, min_current_ratio or custom_ratio), threshold (whole currency units for min_cash, a decimal ratio otherwise), an optional comparison (gte, gt, lte, lt; the minimums default to gte, max_leverage to lte, and custom_ratio must say), warn_band_bps (a pass within this share of the threshold is a warning; 1000 = 10%), label, notes and active. A custom_ratio also carries numerator_selector and denominator_selector, each naming ledgers by ledger_ids, types (asset, liability, equity, revenue, expense) or concepts (asset.cash, liability.long_term_debt, expense.cogs, …), read as the balance as of the night or the net flow over window_days. PATCH /credit/covenants/{id} changes any of these (active: false stops the check and keeps the history); DELETE /credit/covenants/{id} removes the covenant and its checks.

How each built-in kind is measured: min_cash is the balance of the cash, petty-cash and undeposited-funds ledgers; max_leverage is total liabilities over equity, where equity is total assets less total liabilities; min_current_ratio is current assets over current liabilities, with fixed assets, equipment, accumulated depreciation, notes receivable and security deposits left out of the assets and long-term debt out of the liabilities; min_inventory_turns is cost of goods sold over the trailing 365 days over the average of the opening and closing inventory balances; min_dscr is the debt_service_coverage_ratio underwriting measurer. A figure the books cannot supply (no matching ledger, a zero denominator, a line with no merchant_business_id) reads unmeasured, never zero.

The nightly sweep's covenants step (check_covenants, default on) writes one check per covenant per night; POST /credit/lines/{id}/covenants/check (?as_of=) runs tonight's check now and re-records the night in place. GET /credit/lines/{id}/covenants lists the covenants with latest_check and recent_checks (the last 60 nights, oldest first); it is also open, read-only and without the add-on, to the business that borrows on the line (viewer: borrower). GET /credit/lines/{id}/covenant-checks pages the history newest first. Each check carries value, threshold, status (pass, warn, breach, unmeasured), consecutive_breaches, limit_review_id and inputs: every figure it was computed from, with the ledgers (ledger_ids, each with its cents) and the window (start_date, end_date) behind it, so a tripped value is traced through the money-flow lineage of those ledgers.

A warn or breach night emits credit.covenant_warning or credit.covenant_breach (once per covenant and night) and leaves the borrower a notification; a breach also alerts the operator. The third consecutive breached night files one limit review with source: covenant, status: proposed and recommended_limit_cents equal to the current limit, so a person decides what, if anything, to do, through the same Apply / Dismiss path as any proposal; it is never filed again until the covenant passes and breaches three nights afresh, and it does not count as the program's cadence review. Error codes: CREDIT_850 (404, no such covenant), CREDIT_851 (400, a custom_ratio without both selectors), CREDIT_852 (400, reserved for a line with no merchant books).

Cards

Cards are issued through the program's issuer (issuing_provider: Ramp by default, or Stripe Issuing, see Stripe Issuing); 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. A new card is provisioned with a zero ceiling before its committed headroom is delivered. If the issuer returns a nonzero provisioning ceiling, DayZero attempts to freeze it and refuses the issue for reconciliation.

POST /credit/cards takes credit_line_id; optionally display_name (the label at the issuer: the merchant's legal name, else its external_merchant_id), card_type (virtual, the default, or physical), spend_limit_cents, spend_interval (TOTAL only for DayZero-managed cards), transaction_limit_cents, allowed_mcc_codes (default: the program's list), blocked_mcc_codes, auto_lock_date and idempotency_key; cardholder_provider_user_id is deprecated (see Open a credit line). New periodic limits are rejected because a daily or monthly reset could restore spending independently of the line budget. An explicit positive ceiling must fit the current line/program headroom; it cannot bypass an inactive line or disabled issuing. Omit the ceiling for automatic shared-headroom allocation (requested_spend_limit_cents: null); provisioning still starts at zero. The card's allowed MCCs may narrow the program's list, never widen it, and program blocks remain enforced.

PATCH /credit/cards/{id}/limits saves requested controls and queues delivery: spend_limit_cents, spend_interval, transaction_limit_cents, allowed_mcc_codes, blocked_mcc_codes, auto_lock_date and display_name. A supplied spend_limit_cents is retained as requested_spend_limit_cents, a lifetime cap that later balance changes do not erase. Omission or null keeps that cap; reset_spend_limit_cap: true removes it and returns the card to automatic shared-headroom allocation. Do not send a reset and a new numeric cap together. A zero cap prevents new spending. Clearing transaction_limit_cents is persisted as an explicit zero restriction so an issuer that merges patches cannot retain the old cap. Partner-managed controls must be changed at the partner.

Requested versus delivered. A card response carries requested_spend_limit_cents, issuer_sync_status (queued, pushed or failed), failure_reason, the last issuer-reported spend_limit_cents, and last_synced_at. queued means saved for later delivery; pushed means the delivery pass completed; failed requires inspection of the reason. A locally active card whose reactivation is queued may still be frozen at the issuer. Display those facts separately. POST /credit/lines/{id}/cards/spend-limit-sync queues a fresh recomputation and returns per-card queued outcomes (or skipped when there are no eligible managed cards); it does not synchronously apply a limit. POST /credit/cards/sync reads issuer state and reports reconciliation results; it is not a substitute for the delivery worker.

One line budget across cards. The worker divides the line's spendable headroom using a deterministic max-min fair allocation across eligible DayZero-managed cards, subject to each card's remaining requested lifetime cap. It never intentionally grants the full line headroom independently to each card. A lifetime (TOTAL) ceiling is cleared spend to date plus that card's allocation; a card with no allocation receives zero and stays frozen. A card's spend_limit_cents is the last reported ceiling, not its allocated headroom. GET /credit/lines/{id}/cards/monitor shows the reported ceiling and remaining capacity. Settlements must be attributed to the correct card, including card_last_four when pushing them, so lifetime spend is counted correctly.

Suspension and reinstatement. Explicit card suspension (POST /credit/cards/{id}/suspension) and termination (DELETE /credit/cards/{id}) still call the issuer synchronously as capacity-decreasing operations. Lifting suspension (DELETE /credit/cards/{id}/suspension) records the requested local activation and queues delivery; the worker installs the current shared budget before resuming eligible cards. Repeating a state already recorded returns the card without a second lifecycle event; retry queued or failed delivery through spend-limit sync. Suspending a card the merchant or collections froze takes the hold over (suspended_via: operator), so only an operator lifts it. A line-level suspension follows the asynchronous line status contract.

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 delivered allow list uses 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 rather than guessed. On Stripe Issuing the allow and block lists use Stripe spending-control categories; the exact four-digit MCCs are also kept on the card and enforced on real-time authorization (below).

Durable issuer-control delivery

Origination, repayment, reversal, write-off, line-limit changes and control changes write a coalesced credit_issuer_sync_requests row in the same database transaction as the local change. A rollback removes that enqueue too. Provider capacity increases run only from a dedicated worker transaction after commit; an old request never replays an old numeric limit.

The Temporal credit-issuer-sync-poller runs every minute. Each delivery opens a fresh tenant-bound session, takes the line advisory lock and locks the current line, program, request and card rows before provider calls. It recomputes from committed balances and controls, then:

  1. Reads and freezes every managed fund, verifying suspension before continuing.
  2. Checks fund identity, legacy intervals and mixed-management capacity. Duplicate shared funds, unknown ceilings and active partner-managed funds in the reconciliation prevent increases and leave the managed funds frozen for review.
  3. Applies decreases before increases, sends the full stored control set, and reads back each TOTAL ceiling.
  4. Resumes only locally active cards with a positive allocation, after the complete line budget has been installed.

Legacy periodic cards are frozen and marked suspended_via: issuer_controls; they are not silently skipped or converted. Convert with PATCH /credit/cards/{id}/limits and spend_interval: TOTAL, reinstate, then set any revised lifetime cap. Review prior lifetime spend before choosing the cap. Program MCC restrictions are applied alongside card restrictions; a card whose stored allow list no longer overlaps the program fails for review.

Successful requests remain scheduled for a five-minute drift-repair pass. Failed delivery records per-card failures and the request's last_error, increments attempts, and retries with backoff from 30 seconds up to 15 minutes, subject to the one-minute poll cadence. Database/commit failures leave the durable request due. The poller processes at most 100 due lines per pass and skips overlapping workflow runs, so these cadences are not a guaranteed completion time.

Issuer-control deployment and recovery

  1. Apply Alembic revision c8d4e2f6a901, following d9e4b2f8a731, before deploying code that reads the new card fields or writes the queue. It adds requested_spend_limit_cents, issuer_sync_status, and the tenant-isolated credit_issuer_sync_requests table, preserves existing ceilings conservatively as requested caps, and seeds requests for existing DayZero-managed issuer funds. Existing automatic versus explicit ceilings cannot be distinguished reliably; review before resetting a migrated cap.
  2. Deploy the API and the Temporal worker with CreditIssuerSyncWorkflow and CreditIssuerSyncActivities, then deploy/run the scheduler so credit-issuer-sync-poller exists, is unpaused, and targets the worker's task queue. An API-only deployment can save changes indefinitely without delivering them. The worker needs the same owned issuer connections and server credentials as the API.
  3. Check card issuer_sync_status, failure_reason, spend_limit_cents and last_synced_at, and inspect queue status, attempts, next_attempt_at, last_error and results through tenant-authorized operational access. A successful enqueue or sweep is not evidence of issuer application. Confirm that due requests are being processed and successful rows receive a new drift-repair date.
  4. For a failure, fix the reported connection/ownership, duplicate fund, legacy interval, MCC or mixed-management issue before retrying. Reconcile partner-managed capacity and consolidate management before raising managed budgets on a mixed line. Queue a retry with POST /credit/lines/{id}/cards/spend-limit-sync, then verify the resulting card status and provider state. Do not mark a failed row delivered or remove the queue to hide the failure; a retry recomputes from the current book. A worker crash after remote changes is recovered by the same freeze/recompute/readback sequence.

Verification boundary. These controls are implemented and covered by local automated tests; this change has not been verified against live issuer providers or a production deployment. Retain existing audit events, review findings and failed-attempt evidence: migration and recovery do not rewrite the history or prove that a prior control was enforced.

Stripe Issuing

A program with issuing_provider: "stripe_issuing" issues its cards on Stripe Issuing. Every card call above (POST /credit/cards, card on POST /credit/lines, limits, suspension, termination, embed-token, sync, reconcile-settlements) works unchanged; this section is what differs.

Which Stripe account. The account locator is selected from the line's issuer_config.line_stripe_accounts entry, then the program's stripe_account_id / connected_account_id aliases, then a legacy acct_... in ramp_spend_program_id, then the business's canonical Stripe connection (Integrations → Stripe). Conflicting program aliases are rejected. Every selected account must be the business's canonical connection or be verified through Stripe metadata naming this business and the owned line; editable JSON is not ownership proof. Normal gateway construction and its program-and-line cache use this ownership boundary, including card controls, reconciliation, embed tokens and durable delivery.

Live operations require the server's Stripe credential and a connected account. JSON API keys and live/mock flags cannot override that choice. Explicit or implicit platform card issuance is blocked until a trusted business/program binding and resource-ownership path exist; superuser permission to inspect or configure the platform is not an issuance grant. Sandbox card operations use the credential-free mock, even when live server credentials exist. See Stripe account ownership.

issuer_config.default_cardholder_id supplies the cardholder when configured; otherwise the adapter creates one. Delivered lifetime ceilings map to Stripe spending_limits with all_time, the transaction cap to per_authorization, and MCC lists to Stripe categories plus the exact MCC metadata. The embed token is the secret of a Stripe ephemeral key for the card, for stripe.js to render the number. Issuance starts at zero capacity and controls use the durable delivery sequence above.

Approval and setup. Stripe enables card issuing per account; until the card_issuing capability is active, every issue fails with CREDIT_502 carrying Stripe's reason.

Call Who What it does
GET /credit/cards/stripe-issuing/status Credit reader on a live business; platform access requires a signed-in superuser Reads the owned account's card_issuing_capability, transfers_capability, issuing_api_accessible, tos_accepted, currently_due_requirements, disabled_reason, issuing_balance_available_cents, funding_credit_beta_enabled, webhook_configured, missing_webhook_events, ready_for_issuing and next_steps. Omitted account defaults to the canonical business connection. Only a signed-in superuser may select platform or use the platform fallback for diagnostics; an API token cannot
POST /credit/cards/stripe-issuing/setup Signed-in operator on a live business Validates any supplied program before Stripe mutations, sends the requested business fields, records Issuing terms acceptance, and requests the capability. configure_webhooks modifies platform webhook subscriptions only for a signed-in superuser; ordinary operators' requests skip that step. create_default_cardholder requires an owned program_id. Returns status; it does not authorize platform card operations
POST /credit/lines/{id}/cards/stripe-issuing/connect Signed-in operator on a live business Validates the line/program and verifies an explicit connected account's ownership. Omission reuses the stored line account, recovers it through provider metadata, or creates a Custom account with a stable business/line idempotency key. Optional credit-policy and funding-obligation operations use that account. Onboarding URLs must be supplied together and match the configured app origin. The stored line mapping is used by normal card operations after ownership verification; the platform account cannot be attached

All three endpoints refuse sandbox businesses before any Stripe call. Connected-account diagnostics inspect account-scoped webhooks; a platform operator verifies the shared Connect subscriptions when those diagnostics cannot confirm them. Missing server credentials or invalid ownership on a live program fail readiness instead of silently using a mock issuer. The Stripe integration guide details account recovery and onboarding URLs.

In the console the program form shows a Stripe Issuing & Charge Card readiness panel when the issuer is Stripe Issuing, with Check Stripe account and Activate Stripe Issuing & Webhooks over these two calls.

Authorizations, settlements and disputes. Stripe's Issuing events arrive on DayZero's Stripe webhook endpoint (/api/v1/webhooks/stripe); the shared endpoint's receipt of platform events does not grant platform card access. issuing_authorization.request is answered synchronously: DayZero approves when the card is on a credit line and active, not past its auto_lock_date, the line is active, the amount is within transaction_limit_cents and the line's available_credit_cents, and the merchant's MCC is on the allow list (where there is one) and off the block list; otherwise it declines, and the reason is in the webhook acknowledgement. issuing_transaction.created and .updated originate the loan as a push would (a capture is a positive settlement, a refund a negative one tied to its original transaction), stamp the settlement source: stripe_issuing_webhook, and queue issuer-control recomputation. issuing_dispute.* open a hold on the loan and release it when the dispute is lost or cancelled (see Disputes and voids); issuing_card.updated mirrors a status or limit changed on Stripe; issuing_credit_policy.* and issuing_funding_obligation.* are recorded on the program's issuer_config. The nightly reconciliation lists the card's Stripe transactions for the window, so a missed webhook is caught (cards.ramp_webhook does not apply; see Launch readiness).

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), saves it on eligible active or suspended DayZero-managed cards, and queues issuer delivery per line. 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 separates queued, pushed and failed counts and per-card results; a queued result is not an issuer-confirmed restriction. Inspect later delivery failures and retry after correcting their cause. 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" }

provider_fund_id lets DayZero read the card's spend controls back and is needed to move the card to DayZero later (see Switching who manages the cards). Optional fields: provider_user_id, provider (ramp, stripe_issuing, brex, lithic, external or mock; default the program's), display_name, expiration, cardholder_name, card_type, spend_limit_cents and spend_interval (TOTAL by default).

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; use the newest event to manage the aggregate capacity across the line's partner cards, accounting for lifetime spend where required by the issuer. Do not give every card that full headroom independently. 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 does not deliver controls to a registered partner-managed card.

Both managers can be recorded on one line. A registered card gets the credit.line.limit_changed event even on a dayzero program, and a DayZero-managed card still queues controls on a partner program. The program's card_management is the default for what comes next. This is not permission for two independent copies of the line budget: active partner-managed funds found during delivery block the managed-card rebudgeting pass, leaving managed funds frozen for review. Reconcile partner capacity and consolidate management before raising managed budgets.

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), preserves the current ceiling as its requested cap, and queues issuer-control delivery
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 New cards become the partner's responsibility, but this card stays managed_by: dayzero and uses durable issuer-control delivery until terminated; mixed-capacity checks still apply

Each card also says who manages it and who sets its limit after the switch (managed_by_after, limit_set_by_after). Applied, the switch writes card_management, adopts eligible cards, queues each adopting line, and emits one credit.line.limit_changed (reason: card_management_changed, with card_management and card_managers) for every line whose managers changed; it is recorded in the audit log. The switch response confirms local ownership changes, not completed issuer delivery. Applying the same target again changes, queues 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. days and dispute_lookback_days each take 1 to 120; credit_line_id limits the pass to one line; ingest_missing: false skips originating loans and only checks what is already recorded. A transaction the engine cannot take is named in errors and skipped, and the rest of the cards and transactions are still reconciled. See Disputes and voids. For partner-managed cards, re-push the settlement; ingest is idempotent on the settlement id.

Issuer spend

The card program runs on your own Ramp account, connected to your operator business under Integrations. DayZero syncs that account's card spend into the operator business's books, and a credit or credit:read token reads it back under /credit/issuer, so one key serves the credit book and the card spend; a credit:payments token is refused (403). These calls only read: nothing here changes Ramp or the books. A cardholder is a name; no email or address is returned.

GET /credit/issuer/card-transactions lists the card transactions the Ramp sync has stored, newest first, filtered by start and end (transaction dates, inclusive), card_holder (whole name, any case), card_last_four and state. amount_cents is signed: positive is spend on the card, negative a refund back to it. Each row carries the card (provider_card_id, card_last_four), the cardholder, Ramp's state, the Ramp coding (ramp_category), and, once DayZero has booked it, the account it is booked to (accounting_category, the expense side of the journal entry) and the entry (journal_entry_id); receipt_count is how many receipts Ramp had when the row was synced. When the card is registered to a credit line on this business, credit_line_id names the line, and loan_id the loan when a settlement of the transaction originated one. Reimbursements are left out unless include_reimbursements=true. Spend on a credit-line card is a loan on the line, not the operator's own expense, so the sync never stores it here: read it under the line. Rows synced before DayZero kept the card's last four and the cardholder fill them from the stored Ramp payload and from other rows on the same card; a row whose card nothing stored knows keeps a null last four.

GET /credit/issuer/cards lists the cards on the connection by cardholder: last four, name, cardholder, Ramp's status, the fund's spend limit and interval, how many stored transactions each has, and the credit line a card is registered to. source=live reads Ramp (cards, funds and the cardholders' names) and refuses with 409 CREDIT_740 when the business has no Ramp connection and 503 CREDIT_741 when Ramp does not answer within 20 seconds or the connection lacks cards:read. source=stored builds the list from stored transactions and registered cards without calling Ramp; status and limits are then unknown. source=auto, the default, reads Ramp and falls back to the stored list, with the reason in issuer_note and source saying which you got.

GET /credit/issuer/cardholders totals card spend per cardholder over start..end, highest first: transactions, net spend (charges less refunds), spend in the 30 days ending on end (today when end is not sent), the cards used, the newest transaction and the credit lines their cards are registered to. Declined and errored transactions are not spend. start after end is 400 CREDIT_742 on both dated lists.

Borrower portal

Before sharing a link with the payoff scope, configure at least one usable lender handoff in statement_branding: an HTTPS payment_url, non-empty remittance_text, valid support_email, or lender website. Link creation refuses an empty handoff. payment_url appears as Continue to [lender] to pay; payment execution stays with the lender. Invalid payment URLs, including embedded credentials and non-HTTPS schemes, are refused at program configuration. Older links with no instructions show an explicit missing-instructions message.

The portal labels the amount page Payoff quote. Statement downloads distinguish Issued PDF from Current reconciliation CSV. A requested card reactivation is not shown as issuer-confirmed availability before delivery completes.

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; you switch between polling and both yourself (Turn the push on). 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; source: covenant when a persistent covenant breach filed it (recommending the current limit)
credit.covenant_warning A covenant on the line passed inside its warning band last night; carries covenant_id, check_id, kind, label, as_of, value, threshold, comparison, warn_band_bps and consecutive_breaches. Once per covenant and night
credit.covenant_breach A covenant on the line breached last night; same payload, plus limit_review_id when this was the third consecutive breach and a limit-review proposal was filed. Once per covenant and night; never a limit change
credit.limit_change_request.decided A limit-change request closed: status approved, rejected, cancelled or expired, with limit_change_request_id, credit_line_id, previous_limit_cents, requested_limit_cents, applied_limit_cents (null unless approved), decided_at (the expiry time for an expired one) and decision_note. Once per request. An approval also emits credit.line.limit_changed
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.underwriting.decided The lender approved or declined a completed assessment, or auto-approval opened the line: assessment_id, external_application_id, subject_business_id, program_id, decision (approved or declined), merchant_message, approved_limit_cents, approved_term_days, credit_line_id, decided_at and decided_via (console, api_token or auto). Once per assessment; never carries the internal note. An approval that opens a line also emits credit.line.opened
credit.bank_connection.completed The merchant finished a bank Link started with POST /credit/bank-connections; carries bank_connection_id, external_merchant_id, merchant_business_id, credit_line_id, institution_name, accounts, the repayment account (plaid_account_id, linked_account_mask, linked_account_name), recorded_on_line and completed_at. Once per connection
credit.bank_connection.failed The merchant left the Link, or Plaid could not connect; carries the same identifiers and failure_reason (exited, plaid_error or no_accounts). Once per connection
credit.bank_connection.reconnect_required A pending assessment's merchant has a bank source only the merchant or you can fix, so it waits on bank_reconnect: assessment_id, external_application_id, merchant_business_id, credit_line_id and sources (each kind (plaid_item or bank_feed), id, provider, status, institution_name, last_synced_on, data_through). For a plaid_item, send the merchant a Link from POST /credit/bank-connections with reconnect: true; for a bank_feed, fix the feed. Once per assessment and set of broken sources
credit.card.issued, credit.card.suspended, credit.card.terminated Card lifecycle
credit.card.reinstated A local 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; issuer resumption still waits for durable control delivery
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)
credit.notification.created An in-app notification was recorded for the merchant: carries it, as notification_id, credit_line_id, external_line_id, program_id, event_id, kind, severity, title, body, action, amount_due_cents, due_date, cycle_id, loan_id, detail, dedup_key and created_at. Once per notification

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. A business may have at most 10 endpoints subscribed to credit events (an eleventh, or a change of event_types that would make one, is 400 CREDIT_817); Order-to-Cash-only endpoints are not counted.

Turn the push on

Registering an endpoint does not by itself make a program push. Do it yourself, with the same token:

bash
curl -X PUT https://api.ondayzero.com/api/v1/credit/programs/YOUR_PROGRAM_ID/event-delivery \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"event_delivery_mode": "both"}'

both POSTs each event to the endpoints subscribed to it and still writes the feed; polling stops the push. The response, and GET /credit/programs/{id}/event-delivery, carry event_delivery_mode, event_delivery_locked, pushes and active_credit_endpoints (active endpoints subscribed to any credit event). Setting the mode the program already has changes nothing. Register the endpoint first: both with no active endpoint subscribed to a credit event is 400 CREDIT_820, since it would push to nobody and fail the program's events.webhook_endpoint launch check. Polling stays the record in both modes. webhook (push, and nobody need poll the feed daily for the readiness check) is DayZero's setting: on a program in it the call is 403 CREDIT_819. DayZero can also hold the mode where it is (event_delivery_locked), and then the call is 403 CREDIT_818. A credit token may make the PUT; credit:read may make the GET.

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; credit.notification.created carries a sample notification as data (the same sample GET /webhooks/o2c/event-types lists), so a receiver can be built from the ping. 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
CREDIT_817 400 The business already has 10 endpoints subscribed to credit events
CREDIT_818 403 DayZero holds the program's event delivery (event_delivery_locked)
CREDIT_819 403 The program delivers in webhook mode, which DayZero sets
CREDIT_820 400 both was asked for with no active endpoint subscribed to a credit event: register one first

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. Sent (and the in-app notification recorded) as the limit changes, not with the nightly run, which then sends nothing again
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

On a per-transaction program a notice calls each loan a purchase, as the merchant knows it: "A purchase on your {lender} credit line is due on {date}", "… was due on {date} and has not been repaid", "A purchase is paid in full", and for collections "The oldest unpaid purchase …" and "… across {n} purchases". A cycle-draw program's notices say "loan".

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. A notice does not link to the borrower portal: each one tells the merchant to sign in to the lender's account to see what it owes and download statements, so send a portal link yourself if you want the merchant to use the hosted pages.

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. Notice emails are not events: sending one adds nothing to the event feed (its in-app notification does, below).

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.

In-app notifications

The notices above are also there for you to show the merchant in your own app. Whenever DayZero decides a notice for a line (the same trigger, at the same moment, under the same dedup_key), it records a notification, whether or not that notice's email is on, and announces it on the feed as credit.notification.created. You render it; the trigger, the timing and the words are DayZero's, so a reminder comes from credit.loan.due_soon and never from a calendar of your own. notices.in_app switches the channel (on unless set false, including on a program with no notices at all); switching it on applies to what happens from then on. A line without a merchant_email, and a statement emailed as a PDF (email_statements), still get theirs. Adverse-action notices have none. The nightly sweep's notices step counts them as notifications.

Field What it is
id, credit_line_id, external_line_id, program_id The notification, and the line it is for
event_id The event that triggered it; null for a collections notification
kind due_soon, past_due, loan_paid, statement_issued, limit_changed or collections
severity info (a statement, a loan paid, a limit raised), warning (a payment due soon, a statement due the day it was issued, a limit lowered) or critical (a payment past due, a collections stage)
title, body Plain text in the lender's name, with amounts and dates written out: the notice email's heading and paragraphs. A per-transaction statement carries the §8.1 wording and ends "Due {date}." or, due the day it is issued, "Due now."
action pay (due soon, past due, collections), view_statement (open cycle_id), view_transactions (a loan paid; loan_id) or view_credit_line (a limit change)
amount_due_cents, due_date Outstanding on the loan and its due date; the statement balance and due date; or, for collections, the amount past due and the oldest due date. Null where they do not apply
cycle_id, loan_id The statement or loan it is about
detail Every figure it quotes, as the email's detail; due_on_issue is true on a statement due the day it was issued
dedup_key event:<event id> or collections:<line>:<stage>:<entered on>: the same key as the notice email for the same decision
created_at, read_at, dismissed_at When it was decided, and when you marked it read or dismissed (null until you do)

GET /credit/lines/{id}/notifications lists a line's, and GET /credit/notifications the business's (narrow with program_id or credit_line_id), newest first, keyset-paged with cursor (50 by default, 500 at most). Filter with kind (comma-separated; an unknown kind is 400 CREDIT_816), since (created at or after) and unread_only (neither read nor dismissed). Each page carries unread_count, the unread and undismissed total for the line or the program, whatever the other filters say: the number for a badge. POST /credit/notifications/{id}/read sets read_at, and POST /credit/notifications/{id}/dismiss sets dismissed_at (and read_at if unset); both answer the notification, and repeating one keeps the first time. A dismissed notification is still listed. GET /credit/notifications/{id} reads one, as a row of the lists (its credit_line_id says whose it is), and marks nothing. Reads take credit:read; read and dismiss take credit. An id that is not this business's is 404 CREDIT_815.

A notification is recorded once: a re-run of the sweep, a resend after a failed email, or a replay of the feed records nothing twice and puts no second credit.notification.created on the feed. Take notifications from the feed (or the push, Turn the push on) by notification_id, or poll the list with since. On a sandbox they are recorded exactly as on a live book, from the clock calls and the scenario, so the walk shows them.

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/partner-merchants",
      "arn:aws:dynamodb:us-east-1:111122223333:table/partner-transactions",
      "arn:aws:dynamodb:us-east-1:111122223333:table/partner-repayments"
    ]
  }]
}
bash
python scripts/admin/credit/pull_dynamodb.py --table partner-merchants --table partner-transactions \
  --table partner-repayments --out-dir ./partner_pull
python scripts/admin/credit/pull_dynamodb.py --table partner-transactions --since 2026-04-01 \
  --since-attribute postedAt --limit 5000 --out-dir ./partner_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 partner-merchants --table partner-transactions \
  --table partner-repayments --out-dir ./partner_pull --upload --business-id <sandbox business id> \
  --label "Partner, April" --role transactions=partner-transactions --role merchants=partner-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). The merchants file may carry each line's merchant_legal_name, disbursement_fee_bps and rate_bps (a percent such as 2.5 or a fraction such as 0.025 is read too); a line the seed opens takes them, and a line that already exists keeps its own terms, the plan warning (term_warnings) when the file's differ. Two more files are optional: opening_loans, loans that already existed before the seed's window (the loan import fields: fee, paid buckets, interest to date with accrued_through, written_off), loaded through the import as at opening_as_of, which must be before every other row (CREDIT_755, CREDIT_756), so they are never charged the line's fee again nor accrue interest twice; and write_offs (merchant_id, the loan's transaction_id, written_off_on, optional amount; omitted writes off everything outstanding), applied in date order with the payments, interest accrued up to the write-off date first. Applying (dry_run false) queues a job and returns 202 with it: poll GET /credit/sandbox/jobs/{id} until status is succeeded, when result holds the plan and counts (lines created and reused, settlements accepted and already there, payments recorded, opening loans and write-offs loaded) and every payment, opening loan or write-off that could not be placed, with the reason (skipped_payments and the like). A second seed of the same program while one runs is CREDIT_757; a reset or line delete while one runs is 409 CREDIT_760. 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": "partner-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), the issuing identity (ramp_cardholder_user_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. Two deliberate exceptions, so the email itself can be rehearsed, each with [Sandbox] before its subject. An address on the sandbox program's own Sandbox email recipients (sandbox_email_recipients on POST/PATCH /credit/programs: exact addresses, or @domain for every address there; at most 25, lower-cased, a malformed one is 400 GEN_002), or on DayZero's server-wide credit_sandbox_email_allowlist setting (the same form; empty unless you ask us), is sent to. And with Send sandbox emails to (sandbox_email_redirect_to, one address) every merchant email from the sandbox goes to that address instead of the line's merchant_email, whatever the lists say, its subject ending (for <merchant address>); the notice records the address it went to. Both are set in the console's program Edit dialog under Statements, branding and notices, and are ignored on a live business. In-app notifications are not emails: they are recorded, announced and pushed as on a live book, and after a reset the new book gets them from its first event.
  • 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.

The application flow with no setup. A new sandbox program, still a draft, with no scorecard, no Bank Link return URLs and events on polling, runs the whole application:

  • POST /credit/bank-connections takes any well-formed completion_redirect_uri (https, or http on localhost); it need not be in bank_link_redirect_uris, since a sandbox Link never redirects. A malformed one is still 400 CREDIT_782. Finish the connection with POST /credit/sandbox/bank-connections/{id}/complete.
  • POST /credit/underwriting/assessments on a program with no scorecard gives it the standard seed first: drafted, validated, approved as "DayZero sandbox" and activated, each step in the scorecard's history, so later assessments use the same version. The synthetic bank has no history, so most rules come back unmeasured (never failed); with evaluate_if_ready and auto_approve the assessment completes at once and the line opens for the requested limit (nothing to size it from, so limit_sizing.reason reads sizing_basis_unmeasured), with auto_approval.sandbox: true and the unmeasured rules in unmeasured_keys. A live program opens nothing while a required rule is unmeasured or no limit could be sized (see Underwriting). An application with an external_merchant_id auto-approves on the sandbox without auto_approve; send auto_approve: false to hold a sandbox application for a lender decision, e.g. to rehearse a decline (POST /credit/underwriting/assessments/{id}/decision). To rehearse failed rules or a particular score, complete it with your own figures (POST /credit/underwriting/assessments/{id}/completion).
  • Once the business has an active webhook endpoint subscribed to a credit.* event, a polling program's events are pushed to it too (GET /credit/programs/{id}/event-delivery reads pushes: true). An operator's lock on the mode (event_delivery_locked) keeps it to polling. On a live business none of this applies: the allow-list, an active scorecard and the delivery mode are needed as before.

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. months_closed lists those month closes (closed_on, closed, skipped); the sweep's own close step runs after them and so counts their periods as skipped. One call catches up every accrual day. From then on the sandbox's reads stand on the day it was run to, not on today: the schedule, the payoff quote and the payment preview default as_of to it, and the line's figures (next_due_date) and the portfolio are read as of it. That day is the latest of today, the newest sweep run since the book's first line was opened, and the newest accrual or close; a reset starts the book at today again. 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.

Finish a bank Link. On a sandbox POST /credit/bank-connections calls no one: the merchant's business is created as a sandbox business and hosted_link_url is a placeholder that opens nothing. POST /credit/sandbox/bank-connections/{id}/complete does what the merchant does in Link: { "outcome": "completed" } (the default) lands a synthetic bank on the merchant's business, by default one checking account, Sandbox Business Checking ending 0000 (or accounts: up to ten { "name", "mask", "subtype" }, subtype checking, savings or credit card, and institution_name), marked as already synced so an assessment finds the backfill done (a merchant that already has a synthetic bank gets that one back, with any account it lacks added, so the same account is never connected twice); then the connection completes exactly as a live one does, with the account on the line and credit.bank_connection.completed. { "outcome": "exited" } fails it. A connection no longer pending is 400 CREDIT_786. A reset leaves bank connections and merchant businesses in place.

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".

Remove one line. DELETE /credit/sandbox/lines/{credit_line_id} with { "confirm": "<the line id>" } (or "DELETE") removes that line and everything under it, the same rows a reset removes, and its cards' activity on the mock issuer; every other line stays, so several merchants can be tested side by side. Another confirm is 400 CREDIT_761. While a sandbox job is queued or running, a reset or line delete is 409 CREDIT_760: wait for the job.

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, a payment dated before the line's cut-over is 400 (CREDIT_751; see Statements), and a payment naming a loan imported after that, dated before its own 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. On a live business every call in this section is an operator action: an API token receives 403. Tell us and we run it. On a sandbox any token may run them.

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 become deliverable only once the file is kept; issuer-control requests commit with the import and are delivered later from fresh state.

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 file built from a partner pull carries such a loan at the ceiling instead, the excess listed (see Building the file from a partner pull). 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 become deliverable only after commit; issuer-control requests commit with the apply and are delivered asynchronously.

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

Building the file from a partner pull

A partner whose system holds outstanding balances rather than paid buckets does not have to compute them: DayZero builds the file from a pull of its tables (see Pulling partner test data), the merchants table and the transactions table. Each loan with a balance becomes one row keyed on the original transaction id. Its paid amounts are what the partner's system assessed (the principal, the fee it holds or the line's fee rate on the principal, and the interest to date it holds) less what it reports outstanding: per loan by bucket (principal_outstanding, fee_outstanding, interest_outstanding), per loan as one figure (outstanding, which sits on principal first, then fee, then interest, as the payment order leaves it), or per merchant (balance on the merchants table), when the merchant's figure is tied exactly and the amount paid is spread over its loans oldest first, interest, fee, then principal within each, and the split is written on every row. Every row's accrued_through is the pull date, and the run's as_of is the day after. A loan whose interest is above the program's ceiling is loaded with its interest at the ceiling (and its interest paid at most the ceiling), and the amount above it is listed; the import itself still rejects a row over the ceiling, so it is the file that is reduced, never the import's rule. A loan with no balance is not loaded (it is history), and a merchant whose every loan is repaid still gets a line with no loans. The lines are opened with each merchant's limit, term, fee, rate and start date from the merchants table, and their merchant_email, merchant_legal_name, merchant_state and merchant_address are set with the same update PATCH /credit/lines/{id} makes. With since, the run is a tail: only the transactions that posted from that date, each loaded as it posted (a payment made after cut-over is the line's, not the file's), keyed on the same ids, so a row already loaded is a duplicate.

POST /credit/programs/{id}/loans/import/from-partner-pull (sandbox only, as pulls are) takes { "pull_id", "pull_date", "since"?, "roles"?, "mapping"?, "balance_basis"?, "line_key"?, "open_lines"? }, stages the file as a batch (step 1, under the builder's own mapping), and returns staging_id, batch, run and report. run is what to send with staging_id to POST /credit/programs/{id}/loans/import for the dry run and the apply: as_of, the three control totals, and, while the lines are not open yet, create_missing_lines with placeholder line_defaults so the dry run has lines to check the rows on (every row carries its own fee, term and rate, so the placeholders change no figure; they roll back with the dry run). open_lines: true opens the lines with their own terms and sets their details first; build again with it before the apply. report is the exceptions report: refused (each row of the tables not in the file, with a code and the reason), lines (and lines_to_open), interest_reduced_to_ceiling, repaid_before_cut_over, notes and control_totals. mapping names the partner's fields under merchants and transactions (or auto); balance_basis is loan, merchant or auto (per loan for a merchant whose every loan has a figure, else the merchant's balance). On a live business the same build runs from the operator script scripts/admin/credit/build_import_file.py, which writes the file, the exceptions report and the contact details, and with --apply opens the lines, dry-runs and applies.

Code Status Meaning
CREDIT_770 400 since is after pull_date
CREDIT_771 400 The pull gives no loan to import and no line to open
CREDIT_772 400 mapping names a field the builder does not read
CREDIT_773 400 A cut-over build from a pull with no merchants table

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.

Attaching the history

Phase 2 of a migration attaches the record of how each imported balance came about, beside the book: the payments made before cut-over and the statements the previous system issued. Nothing attached moves a balance, accrues or re-prices interest, posts to the general ledger, appears on a DayZero statement or appends an event, so it is safe to load after the first live statement. The steps are the wizard's: upload, dry run, apply, report.

POST /credit/programs/{id}/history/import/parse takes kind (payments or statements) and the file (CSV or XLSX, the same limits as a loan file), with an optional mapping and amounts_in. The mapping is inferred from the headers; the rows are staged for seven days with their problems. POST /credit/programs/{id}/history/import with { "staging_id", "dry_run", "expected_count"?, "expected_amount_cents"?, "dry_run_token"? } runs it in the request across every line under one savepoint: a dry run (the default), or a control total that does not match, attaches nothing. expected_count counts payments (a split payment once) or statements; expected_amount_cents sums the payments, or the statement balances. The apply takes the dry run's dry_run_token as the loan wizard does (CREDIT_690 when the totals, the mapping or the documents changed since). Rows attach only to lines that exist; history opens none. GET /credit/import-batches/{id}/history-report returns the last run. A loan file's routes refuse a history batch and the reverse (CREDIT_791).

Payments. One row per payment, with external_payment_id, the line key (external_line_id or external_merchant_id), amount_cents and paid_on; or one row per loan a payment was split across, each repeating those and naming the loan (external_settlement_id) with principal_cents, fee_cents and interest_cents, which must add to the payment. method and source_system are optional. A payment is attached settled, dated as given, with the split it carries (allocation_source: partner). A payment with no split is split by DayZero (allocation_source: derived, the method in derivation): the line's imported loans made by its date, oldest first, and within a loan interest, then fee, then principal, against what each loan carried as paid when it was imported, after the payments dated before it. Payments are placed in date order. A payment is refused when it is dated on or after the line's cut-over (it is a payment of DayZero's: record it with POST /credit/payments), when its id is already a payment on the line, when it names a loan the line did not import or one made after it, when a split puts more on a loan's bucket than the loan carried as paid, or when no split can place all of it. The same file again attaches nothing twice: an id already attached with the same amount is a duplicate. A line whose payments do not account for everything its loans carried as paid has a history_incomplete warning; the loans' figures stand either way.

GET /credit/lines/{id}/historical-payments lists them, newest first, each with its split by loan (loan_id lists one loan's). GET /credit/lines/{id}/payments?include_historical=true lists them among the line's payments with historical: true; without it the list is DayZero's payments only. A historical payment is not a payment of DayZero's: it cannot be settled or reversed through the payment routes (404), and the bank-feed matcher never reads it. An operator marks one returned with POST /credit/historical-payments/{id}/reversal (reason, reversed_on), which moves nothing (CREDIT_793 when it is already marked). A correction to a loan's figures is an adjustment of its own.

Loans repaid before cut-over. The snapshot carries the loans with a balance. A loan repaid (or written off) before cut-over is loaded through the loan wizard with history: true: a row that still owes is rejected, and no credit.loan.originated is appended. Such a loan reads paid and, once its payments are attached, carries the day the last one landed as its paid date.

Statements. One row per statement: external_statement_id, the line key, period_start, period_end, statement_balance_cents and, optionally, issued_on, due_date, previous_balance_cents, purchases_cents, fees_cents, interest_cents, payments_cents, credits_cents, amount_due_cents, source_system and document, the file name of its PDF. POST /credit/import-batches/{id}/documents adds the PDFs as files before the apply (10 MB each, up to 200 an upload; scanned for malware, stored as issued; CREDIT_792 otherwise). A row naming a document that was not added is rejected; a row naming none is attached with its figures only, with a figures_only warning. A statement whose period overlaps one of DayZero's statement periods on the line, or ends on or after cut-over, is refused: DayZero's statements start there. GET /credit/lines/{id}/statements lists them under partner_statements (source: "partner_attached", newest first, document_status attached or figures_only), beside DayZero's under statements (source: "dayzero"). GET /credit/lines/{id}/partner-statements/{statement_id}/document returns the PDF as issued (CREDIT_794 for figures only). Close never reads them, so they are never regenerated, re-dated or part of a DayZero period.

The pull's payments table becomes a payments file with build_history_payment_rows (app/api/v1/services/credit_history_payment_file.py): the partner-export mapping names its fields, a split held under allocations becomes one row per loan, and a payment dated on or after the merchant's cut-over is left out as a live payment.

The opening-balance journal

On a program with gl_posting_enabled, an applied loan import posts one journal at its as_of: Notes Receivable for the principal and fee its new loans owe, Interest Receivable for their interest, and the program's funding account for the two together. It is tagged <program>:import_opening:<run> (the batch id; on a per-line import run on its own, the line and its new loans), so a retry posts nothing twice, and a loan is imported once, so two runs never carry the same loan. The result and the report carry it as opening_journal (a dry run shows what it would post, posted: false). On a program that does not post to the general ledger opening_journal is null and nothing is posted, now or later. The tie-out ties after it.

Code Status Meaning
CREDIT_790 400 kind is not payments or statements
CREDIT_791 400 The batch holds another kind of file: a history file sent to the loan routes, or a loan file to the history routes
CREDIT_792 400 A statement document was refused: not a PDF, over 10 MB, more than 200 an upload, or the batch is a payments file
CREDIT_793 400 The historical payment is already marked reversed
CREDIT_794 400 The statement was attached with its figures only; it has no document

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.auto_approve_default no never fails: a program whose auto_approve_default is on while its active scorecard version has no scoring.limit_sizing (or there is no active version) is a warn, since every line it opens on its own would take the limit applied for. In the console it opens the program's scorecards auto_approve_default is off: the lender records each decision
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 or ramp_cardholder_user_id (the issuing identity: the one Ramp user every card is issued to, D-53) is unset. On Stripe Issuing: the Stripe account the program issues on (see Stripe Issuing) does not answer the Issuing API or its card_issuing capability is not active (the console opens the program form on the issuer, where Check Stripe account and Activate Stripe Issuing & Webhooks are), or in production no Stripe API key is configured; a warn when DayZero's webhook endpoint misses an Issuing event or the Issuing balance is empty. 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 or an issuer other than Ramp
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": "The partner 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 (POST /credit/programs with status: active) passes the same gate on a live business, and force_activate with a reason overrides it the same way; a refused create leaves no program behind and does not take the slug. A program created as draft is gated when it is moved to active. The console's new-program form starts as a draft. 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.

The ledger takes loan_id, entry_types (comma-separated), start and end (effective dates) as filters, after (a sequence_no) and limit. An entry has sequence_no, entry_type, effective_date, amount_cents, the principal, fee and interest deltas, balance_after_cents, loan_id and cycle_id when it touched one, source_type, source_id and memo. The entry_type is settlement, refund, draw, fee, interest, payment, payment_reversal, late_assessment, write_off, adjustment, origination, accrual or credit_applied.

What runs on DayZero's schedule

Issuer controls have their own one-minute credit-issuer-sync-poller, separate from the financial sweep and webhook-delivery poller. It delivers committed requests, retries failures and rechecks successful lines for drift after five minutes. The nightly sweep's push_spend_limits step queues recomputation; its completion does not mean the issuer has applied it. Line suspensions and card releases in collections likewise require later control delivery; explicit card suspension remains synchronous. See Durable issuer-control delivery.

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, issuer-control enqueue (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 issuer-control step visits lines with cards and queues managed-card recomputation; the later worker assigns no capacity to a line that cannot spend. 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 financially 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. The issuer-control poller can still deliver committed mock-control requests; that does not advance the financial clock or authorize a live Stripe call.

Every six hours (at 00:40, 06:40, 12:40 and 18:40 UTC) every bank feed that is not removed is synced from your BigQuery dataset, the least recently synced first, sandbox programs' feeds included.

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. The body of POST /credit/sweeps/daily takes as_of and one boolean per step, each true unless you send false: match_bank_settlements, accrue, close_periods, run_collections, push_spend_limits, recognize_fees, snapshot_bank_balances, recognize_interchange, reconcile_settlements, email_statements, send_notices, alert_adverse_actions, run_assessments, review_limits, verify_ledgers and evaluate_readiness; AutoPay due-today events have no flag and always run. sync_cards (card sync) is the exception: it is false on a call you make and true in the nightly run. POST /credit/cycles/close takes credit_line_id, as_of, issue_statements (default true) and dry_run; POST /credit/accruals/run takes credit_line_id and through, with notify_due_soon (default true) to leave out the due-soon events. 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. Loans imported from a previous system were not originated here, so the import posts their opening balance as one journal (see Attaching the history); without it the GL would be short by the imported book. 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 (approved or declined once the lender's decision is recorded, by POST .../decision or by auto-approval; null until then) 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": "Northwind Lending LLC dba Northwind 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 (status, comma-separated, narrows it, e.g. status=pending_acknowledgement for what still waits on the merchant; an unknown status is 400 CREDIT_835), and GET /credit/disclosures/{id} reads one as a row of that list (404 CREDIT_608 when it is not this business's):

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 and new cards start at zero capacity (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 issuer-control recomputation is queued. Card capacity is available only after delivery. Until acknowledgement 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 no increase is queued. When the merchant acknowledges, the new limit is applied through the normal limit path: one credit.line.limit_changed event, carrying disclosure_id, and a durable issuer-control request. The request or review reads approved or applied meanwhile; neither status confirms issuer delivery.
  • 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 issuer-control recomputation is queued. 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 receives a final decline declined The lender's explicitly selected principal reasons, frozen with the decision; never inferred from failed criteria
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 after dispatch authorization sent_at, channel, recipient, notice_id, template_jurisdiction, template_version and pdf_sha256. A pending notice's send_blocked_reason identifies missing/invalid recipient identity, missing final decline, or missing/mismatched selected reasons. An application can be sent without a line when its identity and final decision are valid. The list pages with next_cursor (limit 1 to 500, default 50).

  • POST /credit/adverse-actions/{id}/send renders the approved template, stores the PDF and durably queues the exact email and attachment. A line uses its merchant identity; an application without a line uses its recorded application identity. Missing or invalid email is refused (CREDIT_614); a draft template is never used (CREDIT_602). The historical response status sent and sent_at record dispatch authorization, not confirmed delivery. Read detail.delivery_status for progress. Sending again resumes only an unattempted queued email; it cannot resend an ambiguous or accepted attempt.
  • 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.

Delivery guarantees and recovery. An explicit Send is still required. The unique business/adverse-action key reserves one intent in the same transaction as the approved content. Rollback sends nothing. After commit, queued is recoverable through Dispatch history → Resume queued send, the same Send endpoint, or the nightly notices step (up to 100 queued records per pass). Recovery never creates an intent for a pending notice. A worker commits sending before contacting SES and disables SDK retries for this send. Concurrent workers cannot both claim it. A timeout becomes uncertain; a process death or failure to persist the receipt can leave sending. Neither is blindly retried. The PDF and attempt reservation remain available for operator review.

accepted means SES returned a message ID, not that the merchant received it. The existing authenticated SES/SNS event endpoint reconciles lost receipts using deterministic notice/business tags plus the exact recipient, and records delivered (recipient mail server accepted), bounced, or complaint. A later delivery event cannot erase a bounce or complaint. This requires the deployment's ses_invite_configuration_set to publish Delivery/Bounce/Complaint events with message tags to the existing authenticated webhook. Without those events, operators must reconcile sending/uncertain against provider logs; the application deliberately offers no blind resend. This is not exactly-once external delivery. Legacy notices without delivery state are not replayed. Other borrower and statement email paths retain their existing delivery behavior.

The console's decline form lists only the assessment program's underwriting_decision_policy.decline_reason_catalog, starts with nothing selected and honors require_decline_reasons. Lenders must configure approved reason wording and compliance policy, approve the applicable legal templates, supply valid recipient identity, and operate the unresolved-delivery queue. No configuration or live message is enabled by these controls.

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 The line or application has no valid merchant email
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
GET /credit/scorecard-seeds Any The seeds DayZero ships: each id, name, description and what it carries
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, DayZero ships seeds: whole scorecards kept as files in the API, one per lending policy. GET /credit/scorecard-seeds lists them, each with its id, name, description and what it carries (criteria_count fixed-threshold rules, limit_criteria_count rules that scale with the requested limit, manual_checks_count). The list is the files on disk, so a seed DayZero adds appears without any other change. Today it holds standard: the launch credit-line scorecard, ten fixed-threshold rules (the program layer, the same ten as scripts/admin/credit/scorecard_example.json), three limit-scaled rules and five manual checks.

POST /credit/programs/{id}/scorecards/from-seed with { "seed": "<id>" } (operator only; standard when seed is left out) creates a draft from one. With no scorecard yet, the draft is the first version of a new default scorecard named for the seed, holding every rule, 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, 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 and its change note names the seed ("Started from the 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 a seed on the scorecards page (shown while the program has no scorecard) is a menu of this list; picking a row starts the draft and opens it.

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, or seed names no seed file (the message carries the reason and, for an unknown id, the ids that exist)

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
segments Optional per-segment threshold overrides, keyed by segment name, each a replacement value (or an object with a threshold). They apply when the run names that segment: on POST /credit/underwriting/assessments/{id}/run, on the completion call, and on a version preview
allow_annualized Optional, off by default. When the measure reads insufficient_history but the history covers at least min_history_days, the rule is scored on the measure's annualised figure (evidence.annualized_value, read from annualized_from_days days) and the result carries annualized: true. Only measures listed annualizable by GET /credit/underwriting/measurers supply one; on any other the validator warns annualize_unsupported
min_history_days With allow_annualized: the fewest days of history the annualised figure may come from, 90 to 365, default 180

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.

Backtest a version. POST /credit/scorecard-versions/{id}/backtest (an operator action) runs a version over a cohort and answers 202 with the job. Name the cohort with business_ids (up to 500, each one your token can access) or firm_id (every business you can access in that firm); GET /credit/underwriting/subject-businesses lists the candidates. limit_mode says where each business's requested limit comes from: fixed (the default: requested_limit_cents for all), line_limit (the business's existing line on your book), per_business (requested_limits_cents, a map of business id to cents) or none. requested_term_days and as_of are optional, and compare_version_id scores every business under a second version too and reports which come out differently (outcome_changed per business, changed_outcomes in the summary). Poll GET /credit/scorecard-backtests/{id} until status is completed or failed (it is queued or running before, with progress_done of progress_total): the summary holds per-criterion passed, failed, required-failed and unmeasured counts, the score distribution and the grade mix, and results has one entry per business. GET /credit/scorecard-backtests/{id}/export downloads the per-business table as CSV, and GET /credit/scorecard-versions/{id}/backtests lists a version's backtests without the per-business rows. A backtest only measures: no assessment is created and no event is emitted.

Other reads: GET /credit/scorecards/{id} returns one scorecard, GET /credit/scorecards/{id}/versions its versions, and GET /credit/scorecards/{id}/audit takes version_id to limit the trail to one version. Opening or editing a draft takes an optional change_note (up to 2,000 characters).

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, the optional_connections it also reads when the merchant has them (meta_ads for the ad-spend measures), 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. For trailing_12m_ad_spend, Meta Ads spend that covers the window (below) counts as history too. 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, and with bank transactions covering under 90% of the year (from the first one) it is unmeasured with insufficient_history: no NSF fee in a fortnight of transactions is not none in twelve months. 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.

Data that may be wrong reads as unmeasured too, never as a figure. Stale bank data: average_daily_cash_balance, cash_runway_months, nsf_count_12m and shadow_debt_and_mca_90d are unmeasured with bank_data_stale while any of the merchant's bank sources is broken (a Plaid connection in requires_reauth, error, invalid, unavailable or no_accounts; a bank feed in error for not_shared, not_found, auth_failed or not_configured) or behind (its last good sync, or a feed's newest balance, more than 3 days (balances: 4) before the day measured); the evidence lists each source in bank_sources. The nightly snapshot does not write a balance for a broken or behind Plaid connection, so a last balance is never carried forward as today's. A sandbox business is exempt. Currency: the bank measures sum only balances in the business's own currency; others are left out, never converted, and counted in days_by_source.excluded_currency_accounts. Uncategorised income: Uncategorized Income is where every unsorted deposit lands (transfers, loans, the owner's money, as well as sales), so trailing_12m_revenue, trailing_3m_revenue (and the ratios built on them) and debt_service_coverage_ratio leave it out of revenue, and when more than 10% of the window's income is in it they are unmeasured with uncategorised (a bare merchant business whose deposits are all there reads uncategorised, not its bank inflows); the evidence gives uncategorised_revenue_cents. Debt: existing_debt_facilities is unmeasured with no_debt_schedule for a business that keeps no debt schedule and has not stated it carries none; a schedule whose borrowings are all paid off is a measured 0. Refunds: shopify_refund_rate needs 90% of its year in Shopify history like the sales measures (insufficient_history).

Annualised figures. trailing_12m_revenue, nsf_count_12m, shopify_trailing_12m_revenue and shopify_refund_rate (annualizable: true in the catalogue) still report insufficient_history on a short history, but with at least 90 days their evidence carries annualized_value (a total scaled to the 365-day window, or the rate over the days held) and annualized_from_days. Only a criterion with allow_annualized is scored on it (see Criteria).

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 ad 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.

The three ad-spend measures (trailing_12m_ad_spend, ad_spend_to_revenue_ratio, ad_spend_to_revenue_ratio_90d) read two sources for the same window: the Advertising & Marketing ledger, and the spend Meta Ads reports for the merchant's ad accounts when the merchant has connected Meta Ads. They report the larger of the two, never the sum: a Meta charge on a card DayZero sees is in both, the ledger misses spend paid from an account DayZero does not see, and Meta misses every other platform. evidence.source says which was used (categorised_ledger or meta_ads), and the evidence gives both figures (ledger_ad_spend_cents, meta_ads_spend_cents) with ad_spend_cents the one measured. Only ad accounts billed in the business's own currency count; the others are left out, never converted, and counted in meta_ads_excluded_currency_accounts. Meta's spend is used only when it reaches back over at least 90% of the window (from its first day of spend, meta_ads_history_start, to its last synced day, meta_ads_synced_through; meta_ads_covers_window) and its last sync is no more than 3 days before the day measured; a connection that has stopped syncing, usually because it needs reconnecting (meta_ads_needs_reauth), reads meta_ads_stale: true and the ledger's figure stands alone. When Meta's spend covers the window, trailing_12m_ad_spend is measured even for a merchant whose books are younger than it; the two ratios still need the window's revenue from the books.

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. Shopify reports disputes only on Shopify Payments charges, so the rate divides by the orders paid through Shopify Payments, and a store with none in the window is unmeasured with no_shopify_payments_orders. Its evidence is chargebacks, inquiries, shopify_payments_orders, orders, window_start and disputes_synced_on.

The Shopify measures read the store's synced orders and refunds (evidence.source: shopify_orders): gross sales are merchandise before discounts on the shop day the order was processed, refunds count on the day refunded, voided and test orders are left out, and days are the shop's own calendar days. Only orders in the business's currency are read; a window whose orders are all in another currency is unmeasured with shopify_other_currency. Stored orders stop at the last pull, so when no connected store has been pulled in the 3 days before the day measured (a revoked token, an uninstalled app, a partner pull not repeated) the Shopify measures are unmeasured with shopify_data_stale. A Shopify pull (POST /credit/shopify-pull) reads up to 20 pages of orders per call; a larger store answers orders_resume_pending: true and keeps its place, and the next call carries on from it until orders_complete is true. The store counts as pulled, as of the first call, only then. 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 orders 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.

Settings chosen when a token is minted (POST /api/v1/tokens; Settings › Developers sets the same): expires_in_days (1 to 365, default 365), business_ids (up to 50 businesses it is limited to; any other business answers 403 AUTH_070), scopes, allowed_ips (addresses or CIDR ranges the token may be used from; any other address answers 403 AUTH_028; fixed at minting, so mint a new token to change the list) and rate_limit_per_minute (10 to 6,000; omit for the platform default). GET /api/v1/tokens/client-ip returns the address your request arrives from, for filling in allowed_ips.

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).

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_credit_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_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.