Docs / API and agents
Applications and credit decisions API
Submit a signed credit application (public) and record, list and read credit decisions (credit team): routes, who may call, validation, errors.
Two controllers: public intake (submission.controller.ts) and decisions (decisions.controller.ts). All routes are under /v1.
Routes#
| Method and path | Who | What |
|---|---|---|
POST /applications | public; a valid session only attributes the submission to its owner | submit a signed application |
POST /applications/:id/decision | session with applications.decide on the supplier side of that application, or staff | record approve, decline or counter |
GET /applications/:id/decisions?page= | session, tenant-scoped | decision history of one application |
GET /applications/:id/decisions/:decisionId/signature | session, tenant-scoped | the signature image as image/png or image/jpeg |
Submit#
Requires an Idempotency-Key header; a retry with the same key and body replays the first answer (200), the same key with different information is a 409. The body names the supplier (vendorOrgId, configVersionId), the business (legalName, contactName, contactEmail, address, requestedLimit, the preferred-terms answer in customResponses.terms ("Net 30", "Net 60" or "Net 90" is stored as requestedTermsDays; any other text is stored as not specified), optional bank summary), up to 10 references, signedByName, certified: true and a base64 PNG or JPEG signatureImage. The server decodes the image and refuses an invalid one.
| Status | When |
|---|---|
| 201 | created: { id, code }. The submission, references, receipt and supplier notices commit in one transaction with their outbox rows and the queue job that sends them; the response does not wait for the email provider, the emails leave a few seconds later (and are retried by the outbox if the provider is down) |
| 400 | validation (fieldErrors in details), missing key, no published application for that supplier, bad signature or sales-rep link |
| 409 | the supplier published a newer version of the application since the form loaded; or the key was reused for different data |
| 413 | body too large |
| 429 | more than 5 submissions per IP per hour |
Principals and the personal guaranty. When the supplier's published form sets requirePrincipals, the body carries principals: 1 to 10 owners or officers, each with name (required), title, ownership (whole percent, all together at most 100), ssnLast4 (exactly four digits; a longer value is a 400, so a full SSN cannot be stored) and an optional email. A form that does not ask for principals refuses a non-empty list (400 fieldErrors.principals); an empty or missing list is 400 when they are required. The principals are written in the same transaction as the application, with the SSN digits stored in the sealed (encrypted) column; while the deployment still dual-writes (PII_WRITE_PLAINTEXT unset or true) the same digits also remain in the old plaintext column until the contract step drops it. The personal guaranty is the existing guarantyAccepted: true flag, required when the form sets requirePersonalGuaranty and covered by the application's own signature (no separate signing); the signed submission record keeps the principals without the SSN digits, but with their names and emails (it is retained evidence, see the pii design doc). Publishing a guaranty requirement with requirePrincipals: false is a 400 on PATCH /admin/credit-app-config; saving a draft is never refused, and a client that omits requirePrincipals gets it set to the value of requirePersonalGuaranty.
A hidden company_url field is a honeypot: when filled the request is answered 200 { ok: true } and nothing is stored.
Decide#
Body: action (approve, decline, counter), approvedLimit (required for approve and counter), approvedTermsDays (30, 60 or 90; default 30; anything else is a 400), decisionNote, reasonCodes, signedByName and a drawn signatureImage are required. A decline must carry 1 to 4 reasonCodes (unfavorable_trade_references, insufficient_trade_references, slow_payment_history, insufficient_time_in_business, insufficient_financial_information, unfavorable_bank_information, requested_limit_not_supported, incomplete_application, other; other also needs decisionNote), otherwise 400. They are stored on the decision record and audit event and printed, with the ECOA statement, in the decline email. Approve sets status APPROVED, decline DECLINED (both final), counter returns the application to REVIEW. The decision record, signature, audit event and the applicant notification commit in one transaction. The email itself is sent by a queue job a few seconds after the response, not inside it.
| Status | When |
|---|---|
| 200 | { ok, status, decisionRecordId } |
| 400 | invalid body |
| 403 | no applications.decide, or the application belongs to another organization (checked in that order, then body, then rate limit) |
| 404 | no such application |
| 409 | the application already has a final decision, or another decision won the race |
| 429 | more than 30 decisions per user per minute |
The signature route answers with Cache-Control: private, no-store, nosniff and a locked-down CSP so the image is inert if opened directly.
Required documents#
If the supplier set a document type to required (see the Documents API) and the application, at its requested limit, has no accepted file of that type, an approve or counter answers 409 with code: documents_missing and details.missing, the list of types. A decline is never blocked. A caller holding documents.review can send documentOverrideReason (5 to 500 characters) to go ahead; the reason is recorded in the audit event document.review_overridden together with the decision id. Without that permission the override is a 403.