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
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/jsonA 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/docsrenders every endpoint, request body, and response. No token is needed to read it. - OpenAPI spec:
GET /api/v1/credit/openapi.json(also committed asdocs/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 thebaseUrl,token, andbusinessIdcollection 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:
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.
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.
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:
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:
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.
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:
{
"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.
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 fromopened_onare taken back with anadjustmentregister 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_dueand fires nocredit.loan.past_dueorcredit.loan.due_soonwhile held. - AutoPay. No
credit.autopay.dueis 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_idnaming it, orpayoff-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_centsand not inpast_due_centsornext_due_date. The Statement Balance is never restated;amount_due_centson the line and the summary band is what remains of the statement less the part of it on held loans. Statement periods and documents carryon_hold_cents(the part ofoutstanding_centson held loans). Each loan row carrieson_holdandhold_status: as the statement was issued on the issued rows, today's onview=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, andprincipal_reversed_cents,fee_reversed_cents,interest_reversed_cents. A held loan cannot be written off (400CREDIT_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(thetransactions.declinedwebhook, or the state on nightly reconciliation;VOIDED,CANCELLEDandREVERSEDfrom other sources are read the same way) goes on avoidedhold. With nothing paid on the loan it is resolvedvoidedat once. With payments on it the hold waits for an operator to resolve it (voided, orreleasedif the void does not stand). - Ramp has no disputes resource and no dispute webhook. A transaction carries a
disputeslist (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). ACREATED_*entry opens adisputedhold 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_RAMPis 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
daily interest = remaining principal × (rate ÷ 10,000) ÷ 365
remaining principal = original principal − principal repaid − credits appliedEach 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.
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.
# 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:
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_idto allocate to one loan; the program's per-loan minimum applies, or the outstanding amount when smaller. The loan must be open onpaid_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 aloan_idthat is not on the line, was not yet originated (or, for an imported loan, not yet imported) onpaid_on, is written off, or was paid off on or beforepaid_onis400(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 sameexternal_payment_idcan be sent again with a correct loan. Quote one loan withpayoff-quote?loan_ids=LOAN_ID. - Pending debits: pass
"pending": trueto record an initiated debit without allocating, then confirm withPOST /credit/payments/{id}/settlementandsettled_on. The payment counts from the settled date. Sending the settlement again with the samesettled_onreturns the payment as settled and allocates nothing a second time, so a settlement that timed out is safe to repeat with its date. A differentsettled_onon a payment already settled is refused with400CREDIT_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 withbank_settlement_matchingon, the bank feed can confirm it for you (below). By then the money has moved, so a settlement is always booked atsettled_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 keepsrequested_amount_centsbeside theamount_centsthat 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
adjustmententry; 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 (onecredit_appliedregister 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 withCREDIT_650orCREDIT_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_651included; 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}/reversalwithreversed_onandreasonreopens 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 throughreversed_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 afterreversed_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 readsissuedagain, with its remaining figure and the line'samount_due_centsrestored, and the register'spayment_reversalentry carriesreasonas its memo. A loan the return leaves owing past its due date ispast_dueat once and, unless it is on hold or was announced before, gets itscredit.loan.past_dueevent then (see Events). - AutoPay: where
autopay_enabledis true, the daily sweep emits onecredit.autopay.dueevent for each outstanding loan that is due (itsdue_dateis 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 isautopay.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:
{
"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.
GET /credit/lines/{id}/credit-balance?status=openThe 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_idon the line), where the merchant connected it through DayZero — on your business, or on the merchant's own DayZero business named inmerchant_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:
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_ratioreadsno_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:
{
"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:
{
"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:
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.
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:
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:
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:
salt: 16 to 32 random bytes, hex.iv: 12 random bytes, hex.key= PBKDF2-HMAC-SHA256(your API token,salt, 100,000 iterations, 32 bytes).- AES-256-GCM-encrypt the Shopify token with
keyandiv, with the normalisedshop_domain(<store>.myshopify.com, lowercase) as additional authenticated data. Send the ciphertext base64 asencrypted_tokenand the 16-byte tag hex asauth_tag. token_hash= hex SHA-256 of{salt}:{shop_domain}:{shopify token}.
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:
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:
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.
"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:
- 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. - Clamp. Into the floor and cap, and the program envelope.
- Steps. No more than
max_step_up_bpsabove the current limit, no more thanmax_step_down_bpsbelow it. - Triggers. A
step_down_bpstrigger that holds caps the limit at the current limit less its cut; when several hold, the largest cut wins. Afreezetrigger 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:
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:
- Reads and freezes every managed fund, verifying suspension before continuing.
- 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.
- Applies decreases before increases, sends the full stored control set, and reads back each
TOTALceiling. - 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
- Apply Alembic revision
c8d4e2f6a901, followingd9e4b2f8a731, before deploying code that reads the new card fields or writes the queue. It addsrequested_spend_limit_cents,issuer_sync_status, and the tenant-isolatedcredit_issuer_sync_requeststable, 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. - Deploy the API and the Temporal worker with
CreditIssuerSyncWorkflowandCreditIssuerSyncActivities, then deploy/run the scheduler socredit-issuer-sync-pollerexists, 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. - Check card
issuer_sync_status,failure_reason,spend_limit_centsandlast_synced_at, and inspect queuestatus,attempts,next_attempt_at,last_errorandresultsthrough 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. - 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:
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.
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 |
/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_idorlinked_account_maskon 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 asPATCH /credit/lines/{id}withautopay_enabled, socredit.autopay.dueevents 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_viaisborrower_portalwhile the merchant's own freeze holds,collectionswhile a collections stage holds it, andoperatorwhen 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
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:
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
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/eventsand 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.
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)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:
"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):
{
"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"
]
}]
}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_pullEach 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:
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-merchantsBoth 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 ""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.
accessreportsmembers_added,members_removed,members_updated,hidden_members_removed,firm_links_added,firm_links_removed,firm_links_updated, andclient_access_added,client_access_removedandclient_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_clearednames 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_notesays 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_demoflag, 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
400withcode: "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. CheckGET /credit/environmentfor each program'sissuing_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 oncredit.statement.emailedwithsuppressed: "sandbox", and the sweep counts both assuppressed. 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_recipientsonPOST/PATCH /credit/programs: exact addresses, or@domainfor every address there; at most 25, lower-cased, a malformed one is400 GEN_002), or on DayZero's server-widecredit_sandbox_email_allowlistsetting (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'smerchant_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-connectionstakes any well-formedcompletion_redirect_uri(https, or http on localhost); it need not be inbank_link_redirect_uris, since a sandbox Link never redirects. A malformed one is still400CREDIT_782. Finish the connection withPOST /credit/sandbox/bank-connections/{id}/complete.POST /credit/underwriting/assessmentson a program with no scorecard gives it thestandardseed 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); withevaluate_if_readyandauto_approvethe assessment completes at once and the line opens for the requested limit (nothing to size it from, solimit_sizing.reasonreadssizing_basis_unmeasured), withauto_approval.sandbox: trueand the unmeasured rules inunmeasured_keys. A live program opens nothing while a required rule is unmeasured or no limit could be sized (see Underwriting). An application with anexternal_merchant_idauto-approves on the sandbox withoutauto_approve; sendauto_approve: falseto 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, apollingprogram's events are pushed to it too (GET /credit/programs/{id}/event-deliveryreadspushes: 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:
{ "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:
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:
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
GET /credit/finance/journal?period=2026-04&program_id=PROGRAM_ID&format=jsonEvery 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
GET /credit/finance/borrowing-base?as_of=2026-05-10&program_id=PROGRAM_IDThe program's borrowing_base_policy decides eligibility:
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 downAccrued 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
"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>/disclosurefrom a portal link that carries theacknowledgescope (see Borrower portal), reads the figures or downloads the PDF, and presses Acknowledge. The line then moves toactive(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 aPATCHtoactiveis 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 alimit_changedisclosure (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: onecredit.line.limit_changedevent, carryingdisclosure_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, withwithdrawn_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: 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}/sendrenders 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 statussentandsent_atrecord dispatch authorization, not confirmed delivery. Readdetail.delivery_statusfor 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:outcomewaived(default: you deliver it yourself, or will not) ornot_required(counsel found none owed).reasonis required.GET /credit/adverse-actions/{id}.pdfreturns 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:
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 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
{
"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
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.
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:
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.
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_programplan by DayZero staff, with the agreement's reference (partner_agreement_ref, required, 1 to 255 characters;400CREDIT_583without 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/creditcall 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 return400BILLING_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) andgrandfatheredbusinesses 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 (
400CREDIT_585) unless the same change keeps it, in the paid or the included add-ons, or by moving topartner_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
draftprogram, and apausedprogram 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 thealert_slack_channelandalert_emailsof its first program in use that has either. It says when the access stops and what to do (move topartner_program, which includes CFO Suite, or add a payment method and keep CFO Suite). - Asking.
GET /credit/billing-lapse-risk(operator;within_days0 to 365, default 14) returnsat_riskand, 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, areasonandactive_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 thepartner_programplan, 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.
python scripts/admin/credit/validate_credit_e2e.py \
--base-url https://api.ondayzero.com \
--token "$DZ_TOKEN" \
--business-id "$SANDBOX_BUSINESS_ID" \
--applyThe 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.