/api/v1/underwrite
Submit 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.
- Scope
underwrite:write- Rate limit
- 100 requests / min
- Idempotency
Idempotency-Keysupported
Authentication
Send your key as Authorization: Bearer …. It must carry the scope underwrite:write; a key without it receives 403 forbidden.
Headers
Idempotency-KeystringReplays the first response for a repeated request. Reusing a key with a different body is idempotency_key_reused (409), never a silent replay.
Request body
application/jsonrequired- product_slug, productSend one of these.
product_slugis the canonical spelling;productis accepted as an alias and is ignored whenproduct_slugis present. - requested_amount, amountSend one of these, in whole dollars.
requested_amountis the canonical spelling;amountis accepted as an alias and is ignored whenrequested_amountis present. - applicant.name, applicant.first_name + applicant.last_nameSend the applicant's full
name, or the splitfirst_nameandlast_name. Whennameis present the split fields are ignored.
product_slugstring | nullrequired(one of: product_slug, product)The product's slug, as listed by GET /v1/products. Required unless product is sent.
productanyrequired(one of: product_slug, product)Alias of product_slug: a string, read only when product_slug is absent. Ignored, whatever its type, when product_slug is sent.
applicantobjectrequirednamestring | nullrequired(one of: applicant.name, applicant.first_name + applicant.last_name)Full name. Required unless first_name and/or last_name is sent.
first_nameanyrequired(one of: applicant.name, applicant.first_name + applicant.last_name)With last_name, the split spelling of name: a string; a non-string part is ignored.
last_nameanyrequired(one of: applicant.name, applicant.first_name + applicant.last_name)With first_name, the split spelling of name: a string; a non-string part is ignored.
emailstringrequiredmin length 1typestring | nullnullableIndividualBusinessindividualbusinessnullIndividual or Business, case-insensitive. Defaults from the product's category when omitted. Any other value is a 400 naming applicant.type.
phonestring | nullnullableeinstring | nullnullableEmployer Identification Number, XX-XXXXXXX or nine digits. Business applicants only. Returned on the case as applicant.ein; sending it here is what makes that field populate, and it is also written into application_data.ein.
ownerobject | nullnullableThe primary owner or personal guarantor. Typed because credit and background checks run against these values — do not put them in application_data. ssn is encrypted at rest and never returned.
first_namestringrequiredmin length 1Owner's legal first name.
last_namestringrequiredmin length 1Owner's legal last name.
ssnstringpattern ^(\d{3}-?\d{2}-?\d{4}|\d{4})$Full SSN (123-45-6789 or 123456789) or just the last four. THE FULL VALUE IS NEVER STORED: it is reduced to the last four at the request boundary and the remainder is discarded, because the bureau integration identifies on last-four plus name, date of birth and address. Never returned by any endpoint.
date_of_birthstringpattern ^\d{4}-\d{2}-\d{2}$YYYY-MM-DD. Required by most bureaus to resolve an identity.
ownership_percentnumbermin 0max 1000-100. The share of the business this owner holds.
titlestringRole at the business, e.g. CEO.
addressstringHome street address.
citystringstatestringmin length 2max length 2Two-letter US state code.
postal_codestringmetadataobject | nullnullableArbitrary key-value pairs for your own reference; stored and returned verbatim.
consentobject | nullnullableThe applicant's authorizations. PRESENCE of the object is what records consent; credit_check is what a bureau pull requires. The timestamp and the caller's IP are recorded server-side and cannot be supplied.
credit_checkbooleanTrue when the applicant authorized a consumer credit report on the owner named in applicant.owner. This is the permissible-purpose assertion the bureau pull relies on.
background_checkbooleanTrue when the applicant authorized identity and background verification.
termsbooleanTrue when the applicant accepted your terms of service.
accuracybooleanTrue when the applicant attested that the information supplied is accurate.
agreement_urlstringLink to the exact agreement text the applicant accepted. Stored for audit.
accepted_text_versionstringYour version identifier for that text, e.g. esign-v3. Stored for audit.
requested_amountnumber | string | nullrequired(one of: requested_amount, amount)WHOLE DOLLARS, never cents: 0 to 2147483647, as a number or a numeric string. Required unless amount is sent.
amountanyrequired(one of: requested_amount, amount)Alias of requested_amount: a number or a numeric string, read only when requested_amount is absent. Ignored, whatever its type, when requested_amount is sent.
application_dataobject | nullnullableThe product's form fields, read by the analysis. Every value is stored as a string; a nested object or array is JSON-encoded.
bank_linkanyThe boolean true also mints a bank-verification link and returns it as bank_link_url. Any other value (including the string "true") requests no link.
bank_verificationstring | nullnullableplaidstatementsnonenullHow this platform proves the applicant's banking. plaid mints a bank-verification link and is the same as bank_link: true. statements means you upload the bank statements yourself to POST /v1/cases/{id}/documents with category financial: no link is minted, no email is sent, and the case waits for those files rather than being analyzed immediately with nothing to analyze — the first financial upload starts the analysis. none underwrites on credit and background evidence alone. Defaults to plaid when bank_link is true, otherwise none. Sending this together with bank_link is accepted when the two agree; a contradiction (for example statements beside bank_link: true) is refused with 400 validation_error naming bank_verification, rather than one silently winning.
Refused parameters4
Sending any of these returns 400 validation_error naming the field. Each entry explains what to do instead.
webhook_url'webhook_url' is not accepted: there is no per-submission webhook destination. Register an endpoint ONCE with POST /v1/webhooks and it receives every event you subscribed to, in this key's mode. This field was previously accepted and ignored, which is why it is now refused rather than silently dropped.
webhookUrl'webhookUrl' is not accepted: there is no per-submission webhook destination. Register an endpoint ONCE with POST /v1/webhooks. The parameter is spelled 'webhook_url', and it is not accepted either.
callback_url'callback_url' is not accepted: there is no per-submission webhook destination. Register an endpoint ONCE with POST /v1/webhooks.
idempotency_key'idempotency_key' is not accepted as a body field: send it as the 'Idempotency-Key' HTTP header.
Response
201application/jsonSerializedUnderwriteSubmissionidstringrequiredstatusstring (enum)requiredpendingapproveddeclinedreviewinvitedpipeline_stagestringrequiredWhere the case is in the underwriting pipeline, as distinct from status,
which is the DECISION. One of:
- queued — accepted, waiting for the analysis to run.
- awaiting_documents — you declared bank_verification: "statements", so
nothing is queued until you upload them. Upload a financial document
and the analysis starts.
- awaiting_evidence — the analysis ran and found nothing to read: no
credit report, no bank data, no background check. Not a failure; the case
is waiting for evidence. Uploading a financial document starts a fresh
run on its own.
- analyzed — an analysis completed. status carries the outcome.
- failed — the analysis could not complete for a reason on our side.
POST /v1/cases/{id}/reanalyze retries it.
productstringrequiredapplicant_namestringrequiredamountnumberrequiredWhole dollars: the amount requested, exactly as submitted.
created_atstring | nullrequirednullableestimated_decisionstringrequiredbank_link_urlstring | nullnullablePresent only when the request sent bank_link: true; absent means no link
was requested. null, with bank_link_status "failed", when the link
could not be created — the application is still accepted, and a link can be
minted later with POST /v1/bank-link.
bank_link_statusstring (enum)readyfailedPresent only when the request sent bank_link: true. "ready" when
bank_link_url carries a link; "failed" when it is null.
bank_link_idstring | nullnullableThe link's id, for GET /v1/bank-link/{id}/status and
/transactions. Present whenever bank_link_url is; null when the
link could not be created.
Without this a link minted at submit was unpollable: the id existed, and
the response dropped it. POST /v1/bank-link returned one, this did not,
so the same resource was addressable or not depending on which door it
came through.
bank_link_expires_atstring | nullnullableWhen the link stops working, so a partner forwarding it to an applicant
knows how long they have. null when the link could not be created, and
also when it carries no expiry.
Errors
The statuses this operation can return, with the error.code values behind each. Every error body is the shared envelope described under Errors in Getting started.
400validation_error401unauthorized403forbidden409idempotency_key_reused429rate_limit_exceeded500internal_error