sumsub
Sumsub bindings for Hot: identity verification (KYC), business verification (KYB) and transaction monitoring (KYT/AML). The package creates and reads applicants, uploads ID documents, moves applicants between levels, resets them and submits them for review, and manages tags and notes. It mints WebSDK access tokens and hosted verification links, creates company applicants and links their beneficiaries, and submits transactions (Travel Rule transfers included) for scoring and review. Webhook deliveries are verified against their digest. A decision helper turns any review into approved, declined or pending. Every request is signed with your App Token, and ::sumsub::api/request covers any endpoint that isn't wrapped here.
Install
Add it to your project's deps in hot.hot:
hot.project.my-app.deps {
"hot.dev/sumsub": "0.1.0"
}
Setup
| Context Variable | Required | Description |
|---|---|---|
sumsub.app.token | yes, unless passed in opts | An App Token from the dashboard (Dev space → App tokens). Sandbox tokens start sbx:, production tokens prd:; a token works only in the environment it was made in |
sumsub.secret.key | yes, unless passed in opts | The token's secret key, shown once when the token is created. It signs every request and is never sent |
sumsub.level.name | no | Default verification level for create-applicant when you pass null. Not secret |
sumsub.base.url | no | Overrides https://api.sumsub.com (sandbox and production share it). Not secret |
sumsub.webhook.secret | for webhooks | The webhook's own secret key from the Webhook manager. It is not the App Token secret |
Signing. Each request carries X-App-Token, X-App-Access-Ts (Unix seconds) and X-App-Access-Sig. The signature is the lowercase hex HMAC-SHA256 of ts + METHOD + path-with-query + body, keyed with the secret key. The body is the exact bytes sent, so multipart uploads are signed byte for byte. Sumsub rejects a timestamp more than a minute from its clock (error 4004), so keep the host clock in sync. ::sumsub::api/sign is the pure signing function.
Per-call credentials
Every function that calls the API has a trailing opts arity. Pass {app-token, secret-key} to act for another tenant; context credentials are then ignored for that call. opts can also carry level-name, and base-url, which needs the pair in the same opts: context credentials are never sent to a per-call URL. A credential that is present but blank or null, or one of the pair without the other, is an error naming the key, never a fallback to context. ::sumsub::api/resolve(opts) is the one resolver: opts first, then context, then defaults. Per-call credentials are masked in call records and never written to context, so they can't leak between tenants or parallel flows.
tenant {app-token: "sbx:tenant-token", secret-key: "tenant-secret"}
applicant ::sumsub::applicants/get-applicant-by-external-id("user-42", tenant)
Quick start
::applicants ::sumsub::applicants
::sdk ::sumsub::sdk
::decisions ::sumsub::decisions
// 1. Create the applicant for your user
applicant ::applicants/create-applicant("basic-kyc-level", {
externalUserId: "user-42",
email: "ada@example.com",
fixedInfo: {firstName: "Ada", lastName: "Lovelace", country: "GBR"}
})
// 2. Send them to Sumsub's hosted verification page
link ::sdk/create-websdk-link("basic-kyc-level", {userId: "user-42", ttlInSecs: 86400})
// 3. When the applicantReviewed webhook arrives (or from a schedule), read the outcome
outcome ::decisions/review-outcome(::applicants/get-status(applicant.id))
outcome.outcome
Functions
Every function below also takes a trailing opts Map (see Setup).
Applicants (::sumsub::applicants)
create-applicant(level-name, applicant): takesexternalUserId(required),type,email,phone,lang,metadataandfixedInfo(firstName,lastName,dob,country,nationality,addresses,tin, ...)get-applicant(id),get-applicant-by-external-id(external-user-id),find-applicant-by-external-id(external-user-id)({exists, applicant?}, no 404)get-status(id):reviewStatusandreviewResult. Alsoget-required-docs-status(id)(per step),get-review-history(id)andlist-levels()move-to-level(id, level-name, doc-sets?),reset-applicant(id),reset-step(id, step),request-check(id, reason?)add-id-document(id, metadata, file): multipart upload. Returns{document, image-id}.list-documents(id)lists the uploadsupdate-fixed-info(id, fields),update-applicant(id, fields),deactivate-applicant(id),activate-applicant(id)approve-applicant(id, {note, tags})andreject-applicant(id, {note, tags, reasons}), for decisions made outside Sumsub- Tags:
add-tags,set-tags,remove-tags(read them fromget-applicant(...).tags) - Notes:
list-notes(id, offset?, limit?),list-all-notes(id, limit),add-note(id, note, tags?),update-note,delete-note simulate-review(id, {reviewAnswer, reviewRejectType?, rejectLabels?}): sandbox only
Hosted verification (::sumsub::sdk)
create-access-token(user-id, level-name, {ttlInSecs, applicantIdentifiers, externalActionId}): a WebSDK or MobileSDK token (POST /resources/accessTokens/sdk)create-websdk-link(level-name, {userId, ttlInSecs, redirect, applicantIdentifiers, lang, ...}): a shareable verification URL (POST /resources/sdkIntegrations/levels/-/websdkLink)
Business verification (::sumsub::kyb)
create-company(level-name, external-user-id, company-info, options):company-infoneedscompanyNameandcountry(alpha-3)update-company-info(id, fields)add-beneficiary(company-id, {types, applicantId | beneficiaryInfo, shareSize, subtypes, natureOfControl}),link-applicant(company-id, applicant-id, types),remove-beneficiary(company-id, beneficiary-id)beneficiaries(applicant): pure.BENEFICIARY_TYPESandUBO_SUBTYPESlist the documented roles
Transaction monitoring (::sumsub::kyt)
submit-transaction(applicant-id, txn)andsubmit-transaction-for-user(level-name, txn); the second is keyed byapplicant.externalUserIdand creates the applicant if neededget-transaction(id),get-transaction-by-txn-id(txn-id),review-transaction(id, "GREEN" | "RED"),rescore-transaction(id),set-payment-txn-id(id, hash),delete-transaction(id)- Travel Rule: submit with
type: "travelRule". Then usetravel-rule-status(txn)andconfirm-wallet-ownership(id, confirmed)
txn ::sumsub::kyt/submit-transaction("65f0c1e2a3b4c5d6e7f80912", {
txnId: "wd-1001",
type: "finance",
txnDate: "2026-11-01 12:00:00+0000",
info: {direction: "out", amount: 2500, currencyCode: "USD", currencyType: "fiat"},
applicant: {},
counterparty: {type: "individual", fullName: "Grace Hopper", paymentMethod: {type: "bankAccount", accountId: "acct-ref-77"}}
})
decision ::sumsub::decisions/review-outcome(txn)
Decisions (::sumsub::decisions)
review-outcome(source)returns{outcome, final, can-retry, on-hold, fraud, review-status, review-answer, reject-type, reject-labels, button-ids, moderation-comment, client-comment}. It accepts a status, an applicant, a transaction, a raw webhook payload or a parsed event.outcomeis"approved"only forcompleted+GREEN, and"declined"forcompleted+RED.- Everything else is
"pending":init,pending,queued,onHold,awaitingService,awaitingUser, or an unknown answer. finalmeans no resubmission (reviewRejectTypeis notRETRY).can-retrymeans the user may upload again.fraudflags Sumsub's automatic-action labels (FORGERY,STOLEN_DOCS, ...).moderation-commentmay be shown to the user.client-commentis internal.
is-approved(source)
Escape hatch (::sumsub::api)
request(method, path, body, opts?),send(...)(the raw response, with headers),sign(...),resolve(opts),page-items(page),next-offset(page, offset)
Webhooks
Sumsub sends X-Payload-Digest, a hex HMAC of the raw body keyed with the webhook's secret key, and names the algorithm in X-Payload-Digest-Alg: HMAC_SHA256_HEX (the default), HMAC_SHA512_HEX or the legacy HMAC_SHA1_HEX.
- Verification:
verify-requestchecks the digest over::hot::http/raw-bodyin constant time. - Failing closed:
- An unset
sumsub.webhook.secrethalts with a message. - An empty explicit secret never verifies.
- A missing digest, or a missing or unknown algorithm, fails.
- An unset
- Algorithms: pass
{algorithms: ["HMAC_SHA256_HEX", "HMAC_SHA512_HEX"]}as the third argument to refuse SHA-1. - Replays: Sumsub signs no timestamp, so there is no replay window to enforce. It retries failed deliveries up to five times over about a day, so make handlers idempotent and key them on
dedupe-key(event). - Sumsub's own example is mislabelled. Its
testDigestexample (secretSoMe_SeCrEt_KeY, bodysomeText, digestf6e92ffe...) is labelled SHA-256 but is actually HMAC-SHA1. The unit tests cover all three algorithms.
sumsub-webhook
meta {webhook: {service: "sumsub", path: "/sumsub/webhook"}}
fn (request) {
cond {
not(::sumsub::webhooks/verify-request(request)) => {
::hot::http/HttpResponse({status: 401, body: "invalid digest"})
}
=> {
event ::sumsub::webhooks/parse-event(request)
outcome ::sumsub::decisions/review-outcome(event)
::hot::http/HttpResponse({status: 200, body: outcome.outcome})
}
}
}
parse-event flattens a payload:
type,applicant-id,external-user-id,level-namereview-status,review-answer,reject-type,reject-labelssandbox,test- the transaction fields
kyt-txn-idandkyt-data-txn-id - the raw
payload
APPLICANT_EVENTS and KYT_EVENTS list the documented types. The transaction on-hold event is applicantKytOnHold, not applicantKytTxnOnHold.
Errors and pagination
- Functions return the decoded JSON body. Simple actions return
{ok: 1}. - A non-2xx response returns an Err carrying
HttpError:status,headersandbody- Sumsub's
error-code(e.g. 4003 signature mismatch, 4004 expired timestamp, 6008 sandbox applicant limit),error-nameandcorrelation-id retry-after, on the temporary 409 from approve and rejectdescription, Sumsub's own textmessage, a readable summary that never includes request data or the description
::sumsub::api/is-error-code(e, 4004)tests a code. Match onerror-code, noterror-name: Sumsub spells some names inconsistently.- Invalid input (no
externalUserId, a transaction withouttxnId, ...) returns an Err before anything is sent. - A GET whose connection fails is retried once, freshly signed. Nothing else is retried in a run: Hot has no sleep. For a 429 or a temporary 409, let a handler or schedule
retrypolicy re-run the work. - Rate limits are 300 GET and 50 POST requests per 5 seconds.
- List endpoints page by
offsetandlimitand return{items, totalItems}, sometimes wrapped inlist.page-itemsandnext-offsetread either shape, andlist-all-notesfollows the pages. - Request bodies omit unset (null) fields instead of sending JSON
null.
Async work
Reviews take time, and nothing in this package waits or polls in a loop.
request-checksubmits the applicant.get-statusreads the review.::sumsub::decisions/review-outcometells you whether the review is done.- The result arrives on the
applicantReviewedwebhook (preferred). Otherwise pollget-statusfrom aschedulehandler. - Transactions are scored synchronously when you submit them, and later review changes arrive on the
applicantKytTxn*webhooks.
Sandbox
Create a sandbox App Token (Dev space, sandbox mode) to test without real checks.
- Results: sandbox runs no real verification. Results come from the "verification test modes" you attach to a level, and
simulate-reviewcompletes a review with the answer you choose and firesapplicantReviewed. - Limits: 500 applicants (error 6008) and 1000 transactions (error 6014) per 24 hours.
- Mock companies: KYB registry checks return mock companies for the GBR registration numbers
99999999,88888888and77777777. - Webhooks: sandbox payloads carry
sandboxMode: true, and test sends from the dashboard carrytestMode: true.
The integration suite (hot test -p pkg-integration-sumsub) only reads: it lists levels and looks up a random unknown externalUserId. It runs with SUMSUB_APP_TOKEN, SUMSUB_SECRET_KEY and optionally SUMSUB_LEVEL_NAME, and skips cleanly without them.
Data handling
Sumsub handles personal data: names, dates of birth, addresses, ID numbers, document images, selfies, and the counterparties and accounts in transactions.
- Error messages: this package never puts that data, or any credential, in an error message.
HttpError.messagecarries only the status, Sumsub's numeric code and the correlation id. Sumsub's owndescription(which can echo submitted values) stays in its own field. - Run traces: Hot records function arguments and results in the run trace. The App Token, secret key and webhook secret are context secrets and are masked there. Applicant data you pass to these functions, or receive from them, is not masked, so treat the run logs of flows that call this package as containing PII and restrict access to them. Pass only what Sumsub needs, and prefer
externalUserIds that are opaque ids, not emails. - Webhooks: webhook payloads carry ids and review results, not personal data. Fetch the applicant only when you need more.
- Bearer credentials: WebSDK links and access tokens let whoever holds them continue that user's verification. Send them only to that user.
- Notes and tags: these are visible to everyone on your Sumsub dashboard.
Notes and limits
- Contract: Sumsub needs a contract for production (sandbox is self-serve after sign-up). Some features are enabled per account: Transaction Monitoring and Travel Rule, KYB registry checks, AML screening, video identification. Calls to a feature your account lacks return 403.
- Not wrapped: applicant actions, the audit trail, case management, wallet attribution and the Travel Rule VASP directory and SDK. Use
::sumsub::api/requestfor them. - Legacy endpoints: the older
POST /resources/accessTokens?userId=...and.../levels/{levelName}/websdkLinkstill appear in places. This package uses the current/resources/accessTokens/sdkand/resources/sdkIntegrations/levels/-/websdkLink. - Changing level:
move-to-levelsends the level as?name=, as the API requires (notlevelName). - Document parts:
add-id-documentsends only themetadataandcontentparts; Sumsub rejects any other part.
License
Apache-2.0 - see LICENSE