Partner Credit Lines

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

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

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

Connecting

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

Tokens are issued under Settings › Developers. The credit surface sits behind the CFO Suite add-on and returns 403 until it is enabled for the business.

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

Status Meaning Handling
400 Invalid payload, unknown filter value, a rule the program has not decided Correct the request
401 Expired token, or an MCP OAuth token used against REST — the two are indistinguishable in the response Do not retry. Use an API token from Settings › Developers
403 Add-on not enabled for the business; a credit-scoped token outside /api/v1/credit; an API token on an operator action (below) Escalate; not retryable
404 No such line, loan, payment, or assessment. For a line, this is how the "no credit line yet" state is detected Check the id
409 Idempotency conflict See the call's notes
5xx Transient Retry with backoff (below)

Timeouts and retries

Requests complete well inside 30 seconds; treat anything longer as a timeout. On a timeout or 5xx, retry with exponential backoff (1s, 2s, 4s, 8s, 16s, five attempts). Retrying is safe on every write in this guide because each is idempotent on a caller-supplied key: settlements on external_settlement_id, payments on external_payment_id, card issue on idempotency_key, and every internal sweep per loan per day. Show the merchant the last figures you have with their as_of timestamp rather than zeroes while a call is failing.

Operator actions

Four endpoints are operator actions performed in the DayZero console by a signed-in person, not calls your product makes: POST /credit/programs, PATCH /credit/programs/{id}, POST /credit/lines/{id}/loans/import, and POST /credit/loans/{loan_id}/write-off. An API token receives 403 on them whoever created it. Tell us and we run them.

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

Page sizes and the paging mechanism differ by endpoint:

Endpoint Default Max Paging
GET /credit/programs, GET /credit/lines 100 500 offset / limit
GET /credit/lines/{id}/subledger (loan rows) 200 1,000 cursor
GET /credit/lines/{id}/loans 100 1,000 cursor
GET /credit/lines/{id}/payments 50 500 cursor
GET /credit/events 100 500 after (sequence number)
GET /credit/lines/{id}/ledger 500 5,000 after (sequence number)

Loans, payments, events, and ledger rows return 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 exists on one endpoint only: GET /credit/lines/{id}/loans, and only when include_total_count=true is passed, because it costs a count query. No other list response carries a total.

GET /credit/programs and GET /credit/lines are offset/limit and return a JSON array (not { items, next_cursor }). For a merchant dashboard, call GET /credit/lines/{id} — do not list every 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.

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

Program parameters

Field Meaning
allowed_term_days Payback terms a line may select. Applied per loan from its posting date
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
minimum_payment_cents Minimum per-loan payment on the per-row Pay action; the outstanding amount when smaller
late_terms Optional: grace_days, day_count (actual_365 default), cap_cents or cap_pct_of_principal_bps on total interest
interest_cap_pct_of_principal_bps Optional ceiling on total interest per loan as a share of its principal. Unset by default, so interest accrues until the loan is repaid (D-07). 2000 (20%) means a $12,000 loan never accrues more than $2,400. The lower of this and any late_terms cap applies
card_management dayzero (default): DayZero provisions cards and pushes available credit as the spend limit. partner: you provision cards in the issuer, register them with POST /credit/cards/register, and push limits yourself from credit.line.limit_changed
event_delivery_mode polling (default): write the outbox only. webhook or both: also POST to a registered endpoint. GET /credit/events works in every mode
issuing_provider ramp, or mock for an in-memory issuer on staging (Ramp has no sandbox)
deferred_fee_recognition Book the disbursement fee to deferred income at origination and recognise it straight-line over the term
email_statements Email the statement PDF to the line's merchant_email when it issues
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
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+)
due_soon_days Days ahead of a loan's due date the credit.loan.due_soon event fires
gl_posting_enabled Post balanced journal entries on the operator's books for every ledger event
legal_lending_entity Printed on every statement PDF
statement_branding Four keys, all optional: logo_data_uri (a data:image/... URI — PNG or SVG — embedded in the document), accent_hex (one colour, used for rules and the amount-due tile), footer_text (e.g. a support contact), remittance_text (payment instructions). Fonts and layout are not configurable per partner

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

Open a credit line

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

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

start_date is the signing date: the effective date named in the credit agreement. PATCH /credit/lines/{id} changes the repayment account and AutoPay.

The 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
unbilled_cents Outstanding on loans originated since the last close
past_due_cents Outstanding on past-due loans
available_credit_cents Limit minus Total Balance. Never below zero
utilisation_bps Total Balance over limit, in basis points
credit_balance_cents Carried-forward credit from refunds or overpayments, consumed by the next loan
next_due_date Earliest upcoming loan due date, else the earliest past due
as_of When the figures were computed. Show it
card_management Copied from the program: dayzero or partner
issuing_provider Copied from the program: ramp or mock

A card transaction posts

Push settled transactions, never authorisations. Idempotent on external_settlement_id; a re-pushed batch reports duplicates rather than double-counting. Up to 1,000 per batch. A negative amount is a refund.

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

Each accepted result carries the loan it originated:

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

A refund result says which branch applied: refund_disposition is reduced_loan when it reduced the loan it relates to (send refund_of_external_settlement_id to name it), or carried_forward when the loan was already repaid or the refund exceeded what was outstanding. Carried-forward credit is applied against the merchant's next loans and never paid out.

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

Loans

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

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

Each loan carries transaction_date, merchant_name, principal_cents, disbursement_fee_cents, term_days, due_date, interest_accrued_cents, the paid buckets, outstanding_cents, and status. The status is authoritative.

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

GET /credit/lines/{id}/subledger returns the Transactions tab in one call: the summary band, the loan rows (same filters), and the period history. GET /credit/lines/{id}/subledger/export returns the same rows as CSV.

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, credit_balance_cents, available_credit_cents, utilisation_bps, and next_due_date.

Interest

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

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

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. An issued statement is never restated; a refund landing afterwards is applied as a credit against the balance.

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, outstanding_cents, period_status (open or paid), 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
pdf The rendered document with the program's branding and lending entity
csv The transaction-level spreadsheet for reconciliation
html The printable page

The CSV has one row per transaction: amount (principal), disbursement_fee, payback_term_days, due_date, interest_applied_this_statement, interest_accrued_to_period_end, and the cumulative principal_paid_to_period_end, fee_paid_to_period_end, interest_paid_to_period_end, then outstanding_at_period_end, status, and originated_this_period. It does not break out individual payments and has no paid-this-period column; per-payment allocation by loan is on GET /credit/lines/{id}/payments.

Payments

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

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

# 2. Execute the debit on your side.

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

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

  • Per-row Pay: pass loan_id to allocate to one loan; the program's per-loan minimum applies, or the outstanding amount when smaller. Quote one loan with payoff-quote?loan_ids=LOAN_ID.
  • Pending debits: pass "pending": true to record an initiated debit without allocating, then confirm with POST /credit/payments/{id}/settlement and settled_on. The payment counts from the settled date.
  • Backdated payments: a payment dated before the last processing date removes the accrual days after it and re-accrues them on the reduced principal.
  • Returns: POST /credit/payments/{id}/reversal with reversed_on and reason reopens the loans at the balances they held. The merchant is not charged interest for the interval the debit was in flight.
  • AutoPay: where autopay_enabled is true, a credit.autopay.due event is emitted for each loan on its due date, carrying the amount to debit. Execute it and report it as a payment.

GET /credit/lines/{id}/payments lists payments newest first.

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

DayZero measures every criterion in the program's underwriting_criteria that has a registered measurer (trailing revenue, average cash balance, existing debt facilities, debt service coverage, revenue concentration, months of history) from the categorised ledger, the bank feed, 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. decision is always null: the lending decision is yours.

Cards

Cards are issued through Ramp; DayZero orchestrates and mirrors, and card credentials never transit DayZero. POST /credit/cards/{id}/embed-token mints a short-lived issuer token so the issuer renders the number inside its own component; each disclosure is audited.

After every origination, repayment, reversal, and write-off, DayZero pushes available credit to the issuer as the card's spend limit, so the card declines at the limit. POST /credit/lines/{id}/cards/spend-limit-sync forces it; POST /credit/cards/sync reconciles state and limits against the issuer and reports drift.

Partner-managed cards

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

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

Idempotent on the issuer card id. From then on, every balance change emits credit.line.limit_changed with available_credit_cents; push that to the issuer as the card's limit. DayZero pushes nothing to a registered card.

Dropped settlement webhooks

For DayZero-managed cards, POST /credit/cards/reconcile-settlements ({"days": 14}) lists the issuer's cleared transactions per card and originates any it never received. The nightly sweep runs it over the last 7 days. A recorded settlement whose issuer state is no longer cleared is reported in voided_or_declined, never reversed automatically. For partner-managed cards, re-push the settlement; ingest is idempotent on the settlement id.

Events

GET /credit/events is the polled feed. Pass the last sequence_no as after; sequence numbers are unique and gapless per business. Program event_delivery_mode is polling (default: outbox only), webhook, or both. Signed POSTs to a registered endpoint fire only in webhook and both. Polling remains the source of truth after a missed delivery.

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
credit.loan.paid A loan was repaid in full
credit.loan.written_off A loan was charged off
credit.autopay.due AutoPay should debit a loan today; carries the amount
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)
credit.line.limit_changed Available credit changed on a partner-managed line; carries available_credit_cents to push to the issuer
credit.cycle.paid A statement period was settled in full
credit.payment.recorded A payment was recorded
credit.payment.settled A pending payment settled and was allocated
credit.payment.reversed A payment was returned or failed
credit.underwriting.completed An assessment finished
credit.card.issued, credit.card.suspended, credit.card.terminated Card lifecycle
credit.card.credentials_requested Card credentials were displayed

Launching with existing loans

POST /credit/lines/{id}/loans/import loads loans that already exist on a previous system 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.

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 active line.

What runs on DayZero's schedule

Nightly at 02:15 UTC, per business: daily accrual and past-due flags, month-end close for any ended period, AutoPay due-today events, bank balance snapshots, deferred fee recognition, interchange estimates, settlement reconciliation against the issuer, statement emails, the issuer spend-limit push (or credit.line.limit_changed for partner-managed cards), pending underwriting measurement, and ledger verification. 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. 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.

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. GET /credit/portfolio/loan-tape?format=csv is one row per loan with every balance and date; it ties to the portfolio figures exactly.

Interchange

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

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

A row may be attributed by issuer transaction id, provider_card_id, or credit_line_id; with none of those, pass program_id and the row lands on the program unattributed. A reported row for a settlement already estimated supersedes the estimate and the operator's books move by the difference (Interchange Receivable / Interchange Income). Idempotent on external_reference. 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.

The scorecard

The scorecard is the partner's (O-01): keys, comparators, thresholds, weights, and which criteria are required. DayZero measures. Check a draft before it goes on a program:

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

The response says whether the set is well-formed, which keys DayZero can measure today, and which required keys it cannot (an assessment can never pass those). GET /credit/underwriting/measurers lists every measurable key with its unit and source. Put the scorecard on the program with PATCH /credit/programs/{id} (underwriting_criteria); the program response carries policy.criteria_coverage. An example that measures fully is at scripts/admin/credit/scorecard_example.json.

Underwriting measurers

Criteria keys with a built-in measurer: trailing_12m_revenue, trailing_3m_revenue, average_daily_cash_balance, existing_debt_facilities, debt_service_coverage_ratio, revenue_concentration_top_customer, months_of_history, months_in_business, trailing_12m_ad_spend, ad_spend_to_revenue_ratio, shopify_trailing_12m_revenue, shopify_refund_rate, shopify_chargeback_rate, nsf_count_12m. Ratios are decimals (0.05 = 5%); money is in whole currency units. A key without a measurer is reported unmeasured, never failed. average_daily_cash_balance is a true 90-day daily average: the nightly sweep snapshots every linked bank account's balance for businesses on a lending program, and days without a snapshot are reconstructed from bank-ledger postings; the evidence says how many days came from each source.

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) credit:payments Restrict to the job's egress IPs
Broader credit integration credit Optional; prefer IPs on any write token

credit / credit:read / credit:payments tokens are refused (403) outside their surface. Tokens with no scope keep full access. Creating 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. To rotate, mint the new key, swap the caller, then revoke the old one.

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 the four operator actions listed under Operator actions, which return 403.

Validate end to end

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

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

The same walk is the CI contract in app/api/v1/tests/test_credit_tie_lifecycle.py. If program creation is refused, pass --program-id. Omit --apply for a dry run of the plan.