Authentication
API key, sent as Authorization: Bearer <key>. A request without a valid key receives 401 unauthorized.
curl "https://<your-host>/api/v1/products" \
-H "Authorization: Bearer hlh_test_YOUR_API_KEY"Every key on this site is the placeholder above. A real key is shown once, when it is created, and never appears in documentation.
Base URL
Relative to the deployment host; the route tree is mounted at /api. Every path in this reference is shown in full, including the prefix.
https://<your-host>/api/v1Scopes
A key carries one or more scopes, and each operation names the scope it requires. A request from a key without it is 403 forbidden. The 15 scopes in use:
applicants:read2 operationsapplicants:write2 operationsbank_link:write1 operationcases:decide1 operationcases:read6 operationscases:write1 operationcollections:read1 operationcollections:write2 operationsdocuments:read1 operationdocuments:write2 operationsportfolio:read4 operationsportfolio:write1 operationproducts:read1 operationunderwrite:write3 operationswebhooks:manage3 operationsYour organization must be verified before it can underwrite. Business verification is submitted and reviewed outside the API. Until yours is approved, every submission is refused with 403 forbidden and no case is created — the message distinguishes a verification that was never submitted from one that was rejected, because what clears them differs. This applies to every route into underwriting, the dashboard included; there is no API-only exemption.
Test and live mode
A key is either a test key or a live key, and everything it creates belongs to that mode.
- A test key never emails a real person. post
/v1/applicants/{id}/send-linkmints the applicant link under either mode and emails it only under a live key. - A webhook endpoint registered with post
/v1/webhooksis created in the mode of the key that registered it and only ever receives that mode's events. Use a live key to register a live endpoint.
Idempotency
Send an Idempotency-Key header on any write you might retry. Replays the first response for a repeated request. Reusing a key with a different body is idempotency_key_reused (409), never a silent replay. The key is a header, not a body field: idempotency_key in a body is refused.
curl -X POST "https://<your-host>/api/v1/underwrite" \
-H "Authorization: Bearer hlh_test_YOUR_API_KEY" \
-H "Idempotency-Key: <a unique value per logical request>" \
-H "Content-Type: application/json" \
--data-binary @body.jsonAccepted on 13 operations:
/v1/applicants/{id}Update an applicant's contact details.post/v1/applicants/{id}/send-linkMint an applicant-facing link, chosen by the `link_type` discriminator, and (for a live key) email it.post/v1/bank-linkMint a bank-verification portal link for an existing case.post/v1/cases/{id}/decisionRecord a binding credit decision, or send the case to a human.post/v1/cases/{id}/documentsAttach a file to a case. Multipart, with one `file` part of at most 25 MiB (26,214,400 bytes) plus the string fields below. De-duplication is by content hash and is PER CASE: the same bytes uploaded twice to one case are refused with 409 `conflict`, and `details.existing_document_id` names the copy. The same bytes on a DIFFERENT case in your organization are accepted — a voided check, an ID scan or a signed application legitimately belongs on more than one case for the same merchant. Nothing is written on the refused path. A `financial` document also starts an analysis of the case; several of them queue one run, not one each.post/v1/cases/{id}/reanalyzeQueue a fresh AI analysis of an existing case. Always 202. A case whose run is already pending or in flight is not queued a second time; that run is returned instead with `already_queued` true, so retrying never stacks duplicate analyses. An already-decided case CAN be re-analyzed: the analysis is evidence, not a verdict, so a new run is recorded and the existing decision and case status are left untouched.post/v1/collections/{id}/activityLog a contact attempt against a delinquent account.post/v1/collections/{id}/escalateEscalate a delinquent account to legal collections, or request its charge-off.patch/v1/documents/{id}/verifyMove a document through its review lifecycle: verified, rejected, back to pending_review, or expired.post/v1/portfolio/loans/{id}/paymentsRecord a payment against a loan.post/v1/underwriteSubmit an application for underwriting. Your organization must have an APPROVED business verification. Until it does, every submission is refused with 403 `forbidden` and no case is created; the message says whether a verification has never been submitted or was rejected, because the remedy differs. Verification is submitted and reviewed outside the API — ask your HL Hunt contact if you are unsure of your organization's status.post/v1/underwrite/batchSubmit up to 50 applications in one request.post/v1/webhooksRegister one, and disclose its signing secret ONCE.Rate limits
Each operation publishes its own limit in requests per minute; it is shown at the top of every operation page. A request over the limit is 429 rate_limit_exceeded in the standard error envelope.
10 / minPOST /v1/underwrite/batch20 / minPOST /v1/cases/{id}/reanalyzePOST /v1/collections/{id}/escalatePOST /v1/webhooksDELETE /v1/webhooks/{id}50 / minPOST /v1/applicants/{id}/send-linkPOST /v1/cases/{id}/decisionPOST /v1/cases/{id}/documentsGET /v1/cases/{id}/report/pdf100 / minPATCH /v1/applicants/{id}POST /v1/bank-linkGET /v1/bank-link/{id}/transactionsPOST /v1/collections/{id}/activityPATCH /v1/documents/{id}/verifyPOST /v1/portfolio/loans/{id}/paymentsPOST /v1/underwrite200 / minGET /v1/applicantsGET /v1/applicants/{id}GET /v1/bank-link/{id}/statusGET /v1/casesGET /v1/cases/{id}GET /v1/cases/{id}/documentsGET /v1/collectionsGET /v1/portfolioGET /v1/portfolio/loansGET /v1/portfolio/loans/{id}GET /v1/portfolio/loans/{id}/scheduleGET /v1/underwrite/batch/{id}GET /v1/webhooks500 / minGET /v1/products{
"error": {
"code": "rate_limit_exceeded",
"message": string,
"request_id": string
}
}Errors
Every error, on every operation, is the same envelope. error.code is one of 8 values; error.message is for people; error.request_id identifies the request if you need to ask about it. param names the request field when the error is about one, and details carries machine-readable context on the errors that have it.
{
"error": {
"code": string, // one of the values below
"message": string,
"param": string, // optional
"details": object, // optional
"request_id": string
}
}conflict409forbidden403idempotency_key_reused409internal_error500not_found404rate_limit_exceeded429unauthorized401validation_error400Money and units
Every monetary field on this API is a whole number of DOLLARS, never cents: $10,378.00 is 10378. Amounts below one dollar are not representable and are refused rather than rounded. The one column stored in cents is BankTransaction.amount, and the read path converts it, so GET /v1/bank-link/{id}/transactions publishes dollars like every other endpoint. Rates are percentages: 11.8 means 11.8 %.
Bank verification
Banking evidence is one of the three pillars the analysis reads, alongside credit and background. There is more than one honest way to produce it, and the API does not assume which one you use — you say so with bank_verification when you submit.
plaidWe mint a bank-verification link and return it on the response. In live mode the applicant is emailed. Identical to the older bank_link: true, which still works.statementsYou already hold the applicant's bank statements and will upload them yourself. No link is minted and nothing is emailed. The case is created and waits for the files rather than being analyzed immediately with nothing to analyze.noneBanking is not part of this decision. The case is analyzed on credit and background evidence alone.Underwriting from statements you already hold
If your platform collects bank statements during its own onboarding — many payment processors and funders do — you do not need open banking on top of it. Send bank_verification: "statements", then upload each statement with the financial category. The first such document starts the analysis: we read the statements back and derive balance, average deposits, net monthly cash flow, NSF activity and lowest recent balance from them, and the case is scored on that.
Uploading several statements queues one analysis, not one per file. Nothing in this flow needs a person on our side, and you never have to ask us to run anything. If a case gains evidence later, or an analysis failed for a reason on our side, a re-run can be queued.
Statements must be text-bearing PDFs. The figures are read out of the document's text. A screenshot, a photograph, or a scan with no text layer contains no text to read, and neither does a placeholder file — so no figures are derived and the case is left with no banking evidence rather than a summary of zeros. If you export statements from online banking as PDF you already have what we need; if your applicants photograph paper statements, those will not parse today.
Only the financial category becomes bank evidence. A signed application, an ID scan, a voided check and an insurance certificate are all useful on the case and none of them is a bank statement — uploading them leaves the case with no banking evidence and nothing to underwrite on. If you attach documents and the case does not progress, this is the first thing to check.
You are not required to declare anything in advance. A case submitted without bank_verification is queued for analysis immediately; if that run finds no credit, no bank data and no background check, the case is left at awaiting_evidence rather than treated as failed, and uploading a financial document starts a fresh run on its own. Declaring statements up front simply skips that wasted first run — pipeline_stage reads awaiting_documents instead, and the case waits for you from the start.
Sending bank_verification and bank_link together is fine when they agree and a validation error when they contradict. We would rather refuse than guess: guessing means an applicant is emailed a bank link by a platform that explicitly asked not to send one.
Webhooks
Register an endpoint once with post/v1/webhooks — the accepted fields are url, events, products and description. It receives every event you subscribed to, in the mode of the key that registered it. products narrows it further: name one or more product slugs and the endpoint hears only about cases for those products. Leave it empty — the default — and it hears about every product. That filter matters when several teams integrate under one organization, which is the normal arrangement: without it, each endpoint receives the other lines' events as well as its own. List your endpoints with get/v1/webhooks and retire one with delete/v1/webhooks/{id}.
curl -X POST "https://<your-host>/api/v1/webhooks" \
-H "Authorization: Bearer hlh_test_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "<url>",
"events": "<events>",
"products": "<products>",
"description": "<description>"
}'- The signing secret is generated by the platform and returned exactly once, on the response that creates the endpoint. Store it then; it is not shown again and cannot be chosen by the caller.
- An endpoint is created
active. Its status becomesfailingbased on delivery outcomes; retiring it is the delete call above. - There is no per-request webhook destination:
webhook_urlandcallback_urlon post/v1/underwriteare refused rather than ignored.
| Event | Sent when |
|---|---|
application.analysis_failed | Underwriting analysis exhausted its retries for a case. The case still exists; nothing was decided. |
application.decided | A binding decision was recorded on a case — approved or declined. The payload is the case summary carrying the new status. |
application.needs_review | A case was routed to a human instead of being decided. The payload is the case summary. |
application.received | A new application has been accepted and queued for underwriting. The payload is the case summary. |
bank_link.connected | An applicant completed a bank connection and transactions are available. The payload is the bank link status. |
batch.completed | Every application in a batch has terminated, whether it succeeded or failed. Fires exactly once per batch. The payload is the batch with its per-element outcomes. |
document.analyzed | An uploaded document finished analysis. The payload is the document with its verification result. |
HMAC-SHA256 over "<timestamp>:<raw body>", keyed with the signing secret returned once when the endpoint was registered. Format: timestamp=<unix seconds>,sig=<hex>. Verify against the RAW bytes you received — re-serialising the JSON changes the digest. Reject a delivery whose timestamp is more than 300 seconds from your own clock. Every retry is signed FRESH, so a retry's timestamp is the moment it was sent, not the moment the event occurred.
import { createHmac, timingSafeEqual } from "node:crypto"
// rawBody must be the exact bytes you received, before any JSON parsing.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=")),
)
const age = Math.abs(Date.now() / 1000 - Number(parts.timestamp))
if (!(age < 300)) return false
const expected = createHmac("sha256", secret)
.update(`${parts.timestamp}:${rawBody}`)
.digest("hex")
const a = Buffer.from(expected, "hex")
const b = Buffer.from(parts.sig ?? "", "hex")
return a.length === b.length && timingSafeEqual(a, b)
}Products
Every application names a product. get/v1/products lists the ones your key may submit against and, for each, the fields that belong in application_data — their names, types, whether they are required, and the permitted values of any choice field. Read it rather than guessing: the list is generated from the same definitions the forms render from, so it cannot drift from what the platform actually collects.
- The catalog is shared, and an organization may also define products of its own. Your key sees the shared catalog plus your organization’s — never another organization’s, whose slugs are simply unknown products to you.
- A product decides what is COLLECTED, not how the application is scored. The analysis reads the applicant’s credit, bank data and background against your organization’s own rules, whichever product the case arrived under.
- get
/v1/casestakesproductas a filter, and accepts several slugs separated by commas — one call for a whole line rather than one per product.
Fields that are always null5
These response fields are published as nullable and are always null today: nothing computes a value for them, and none is estimated or zero-filled. Do not wait for them to populate.
analysis.lowest_balance_30dnumber | nullSerializedBankLinkStatusALWAYS NULL. The platform does not compute a 30-day minimum balance and never publishes an estimate in its place.
bank_data.lowest_balance_30dnumber | nullSerializedCaseDetailALWAYS NULL. The platform does not compute a 30-day minimum balance and never publishes an estimate in its place.
portfolio_yieldnullSerializedPortfolioALWAYS NULL: no value is ever published here. Never estimated, never zero-filled. The platform does not compute a portfolio yield and never publishes an estimate.
30_day_delinquencynullSerializedPortfolioALWAYS NULL: no value is ever published here. Never estimated, never zero-filled. The platform does not compute a 30-day delinquency figure and never publishes an estimate.
60_day_delinquencynullSerializedPortfolioALWAYS NULL: no value is ever published here. Never estimated, never zero-filled. The platform does not compute a 60-day delinquency figure and never publishes an estimate.
All operations30
/v1/applicantsThe organization's applicant directory, paginated.get/v1/applicants/{id}One applicant from the directory.patch/v1/applicants/{id}Update an applicant's contact details.post/v1/applicants/{id}/send-linkMint an applicant-facing link, chosen by the `link_type` discriminator, and (for a live key) email it./v1/bank-linkMint a bank-verification portal link for an existing case.get/v1/bank-link/{id}/statusThe state of one bank-verification request, plus the account summary it produced.get/v1/bank-link/{id}/transactionsThe ingested bank transactions for the case behind one bank-verification request, paginated./v1/casesThe organization's underwriting cases, paginated.get/v1/cases/{id}The full underwriting report for one case.post/v1/cases/{id}/decisionRecord a binding credit decision, or send the case to a human.get/v1/cases/{id}/documentsThe files attached to one case, paginated.post/v1/cases/{id}/documentsAttach a file to a case. Multipart, with one `file` part of at most 25 MiB (26,214,400 bytes) plus the string fields below. De-duplication is by content hash and is PER CASE: the same bytes uploaded twice to one case are refused with 409 `conflict`, and `details.existing_document_id` names the copy. The same bytes on a DIFFERENT case in your organization are accepted — a voided check, an ID scan or a signed application legitimately belongs on more than one case for the same merchant. Nothing is written on the refused path. A `financial` document also starts an analysis of the case; several of them queue one run, not one each.post/v1/cases/{id}/reanalyzeQueue a fresh AI analysis of an existing case. Always 202. A case whose run is already pending or in flight is not queued a second time; that run is returned instead with `already_queued` true, so retrying never stacks duplicate analyses. An already-decided case CAN be re-analyzed: the analysis is evidence, not a verdict, so a new run is recorded and the existing decision and case status are left untouched.get/v1/cases/{id}/report/pdfThe underwriting report as a PDF document./v1/portfolioThe organization's portfolio summary and trailing six-month repayment series.get/v1/portfolio/loansThe organization's funded loans, filtered, sorted and paginated.get/v1/portfolio/loans/{id}One funded loan and its repayment schedule.post/v1/portfolio/loans/{id}/paymentsRecord a payment against a loan.get/v1/portfolio/loans/{id}/scheduleThe amortization schedule, as its own document, with the running balance./v1/underwriteSubmit an application for underwriting. Your organization must have an APPROVED business verification. Until it does, every submission is refused with 403 `forbidden` and no case is created; the message says whether a verification has never been submitted or was rejected, because the remedy differs. Verification is submitted and reviewed outside the API — ask your HL Hunt contact if you are unsure of your organization's status.post/v1/underwrite/batchSubmit up to 50 applications in one request.get/v1/underwrite/batch/{id}Reference generated from the OpenAPI document at /openapi.json.
