Hawk Ai Log in

Documentation

Quickstart, the verification flow, webhooks and the full API reference.

Last updated

Hawk Ai has one job: decide whether the person in front of you is who they claim to be, and show you why. This page is the whole integration.

Quickstart

Everything below works against sandbox. You need an API key: Console → Integrations → API keys.

Your server asks for a session. The link it returns is what you send the applicant — it carries the session that authorises their submission, works once, and expires in seven days.

curl -X POST https://hawkai.ai-sns.io/v2/verification-sessions \
  -H "authorization: Bearer $HAWKAI_KEY" \
  -H "content-type: application/json" \
  -d '{"locale":"en","external_id":"customer-4821","redirect_url":"https://you.example/done"}'
{
  "id": "vs_1a2b3c",
  "token": "vs_74vZM7DHBc12ExNOngO3cQ…",
  "url": "/en/verify?session=vs_74vZM7DHBc12ExNOngO3cQ…",
  "expires_in_days": 7
}

Send the applicant to https://hawkai.ai-sns.io + url. Set locale to any of the 14 supported languages and the whole flow arrives in that language. external_id is your own reference and comes back on the applicant record and on every webhook.

2. The applicant completes the flow

Consent, contact details, document capture, selfie, and — with their consent — the public profiles they confirm. Nothing you need to build.

3. Read the decision

Either poll the applicant, or receive a webhook. Prefer the webhook.

curl https://hawkai.ai-sns.io/v2/applicants/ap_268ae889da \
  -H "authorization: Bearer $HAWKAI_KEY"

The applicant's status is one of approved, review or rejected, with a risk score from 0 to 100 and the factors behind it.

A submission is only accepted with a valid session token. There is deliberately no way to post an applicant into a tenant without one — that used to be possible and it was a security hole, not a feature.

What a decision means

approved — every required check ran and passed.

review — something needs a human. Referral is not rejection; it is the system declining to decide alone.

rejected — a hard block. Four of these cannot be switched off by any configuration: a confirmed sanctions match, a document with tampering indicators, an apparently under-age applicant, and a required check that did not run.

That last one is the design decision worth understanding. A check that could not run is treated as *not passed*, never as scoring zero. If you have not connected a document provider, Hawk Ai will not quietly approve on the strength of the checks that did run — it will refer. checks_ran on the submission response tells you exactly which checks were live:

{
  "id": "ap_268ae889da",
  "status": "review",
  "risk": 5,
  "outcome": "in_review",
  "checks_ran": { "document": false, "liveness": false, "screening": false,
                  "intel": false, "device": true, "footprint": false }
}

Webhooks

Add an endpoint in Console → Integrations → Webhooks. Payloads are signed with HMAC-SHA256 over timestamp.body, retried six times with backoff, and every attempt is in the delivery log.

Verify the signature before trusting the body:

const crypto = require('crypto');
function verify(req, secret) {
  const ts = req.headers['x-hawkai-timestamp'];
  const sig = req.headers['x-hawkai-signature'];       // "sha256=<hex>"
  // reject anything old enough to be a replay
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(ts + '.' + req.rawBody)                    // the RAW body, not re-serialised JSON
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Two things people get wrong: comparing with === instead of a timing-safe compare, and signing a re-serialised body instead of the bytes we sent.

Webhook URLs must be https and must resolve to a public address. Pointing one at a loopback or metadata address is rejected — that would make our server a request-forgery proxy for whoever asked.

Errors

Conventional status codes, and an error field written for a person to read.

StatusMeans
400The request is wrong. field names the offending input where we can tell
401No credential, or an expired verification link
403Authenticated, but not permitted — a role or key scope problem. The message says which
404No such record, in your tenant
409A conflict. The most important one: an approval the compliance engine refuses
429Rate limited. retry_after in seconds
503The deployment is not configured for this yet. blocked_by names what is missing

A 409 on an approval is not a bug to route around. It means a block fired that no setting can disable, and the console shows the same refusal with the reason.

Rate limits

Per IP: 10 sign-in attempts a minute, 5 sign-ups an hour, 20 applicant submissions a minute. In-process, so they stop a script rather than a botnet — put your own limits at the edge if you are exposed.

API reference

There are 97 routes under /v2. This table is generated from the server's own route table, so it cannot describe an endpoint that does not exist.

Authenticate with Authorization: Bearer <token> — either a session token or an API key. The Requires column is the scope a key must hold.

Authentication

MethodPathRequires
POST/v2/auth/loginpublic
POST/v2/auth/mfapublic
POST/v2/auth/logoutany signed-in user
GET/v2/auth/meplatform admin
POST/v2/auth/mfa/setupany signed-in user
POST/v2/auth/mfa/confirmany signed-in user
POST/v2/auth/mfa/backup-codesany signed-in user
GET/v2/auth/mfa/backup-codesany signed-in user
GET/v2/auth/sso/statuspublic
GET/v2/auth/sso/startpublic
GET/v2/auth/sso/callbackpublic
POST/v2/auth/passkey/register/beginany signed-in user
POST/v2/auth/passkey/register/finishany signed-in user
GET/v2/auth/passkeysany signed-in user
DELETE/v2/auth/passkeys/:idany signed-in user
POST/v2/auth/passkey/beginpublic
POST/v2/auth/passkey/finishpublic
POST/v2/auth/passwordany signed-in user
POST/v2/auth/signuppublic
GET/v2/auth/invite/:tokenpublic
POST/v2/auth/invite/acceptpublic
POST/v2/auth/forgotpublic

Applicants and verification

MethodPathRequires
GET/v2/applicantsapplicants:read
GET/v2/applicants/:idapplicants:read
POST/v2/applicantsapplicants:write
POST/v2/applicants/:id/recheckapplicants:write
POST/v2/applicants/:id/decisionapplicants:decide
POST/v2/applicants/bulkapplicants:decide
POST/v2/applicants/:id/endorseapplicants:decide
POST/v2/applicants/importapplicants:write
POST/v2/applicants/:id/screening/:indexapplicants:decide
POST/v2/applicants/:id/notesapplicants:read
GET/v2/applicants/compareapplicants:read
POST/v2/verification-sessionsapplicants:write
GET/v2/verify/session/:tokenpublic
POST/v2/verify/resumepublic
GET/v2/verify/resume/:tokenpublic
POST/v2/verify/documentpublic
POST/v2/verify/submitpublic
POST/v2/applicants/:id/documentsapplicants:write
POST/v2/applicants/:id/eraseconfig:write
GET/v2/applicants/:id/dataapplicants:read
PATCH/v2/applicants/:idapplicants:write

Client configuration

MethodPathRequires
GET/v2/tenant/usageconfig:read
GET/v2/tenant/invoicesconfig:read
GET/v2/tenant/invoices/:periodconfig:read
POST/v2/tenant/planconfig:write
GET/v2/tenant/suppressionsalerts:read
POST/v2/tenant/suppressionsalerts:write
DELETE/v2/tenant/suppressions/:idalerts:write
GET/v2/tenant/filtersapplicants:read
POST/v2/tenant/filtersapplicants:read
DELETE/v2/tenant/filters/:idapplicants:read
POST/v2/tenant/pauseconfig:write
POST/v2/tenant/resumeconfig:write
POST/v2/tenant/rules/simulateconfig:read
POST/v2/tenantsplatform admin
GET/v2/tenant/onboardingconfig:read
POST/v2/tenant/team/:userId/inviteteam:write
GET/v2/tenantconfig:read
PUT/v2/tenant/settingsconfig:write
GET/v2/tenant/teamconfig:read
POST/v2/tenant/teamteam:write
GET/v2/tenant/keyskeys:read
POST/v2/tenant/keyskeys:write
DELETE/v2/tenant/keys/:idkeys:write
GET/v2/tenant/webhookswebhooks:read
POST/v2/tenant/webhookswebhooks:write
POST/v2/tenant/webhooks/:id/pingwebhooks:write
GET/v2/tenant/rulesconfig:read
GET/v2/tenant/flowsconfig:read
POST/v2/tenant/branding/logoconfig:write
DELETE/v2/tenant/branding/logoconfig:write

Platform

MethodPathRequires
GET/v2/planspublic
GET/v2/screening/listsconfig:read
POST/v2/screening/refreshplatform admin
POST/v2/screening/rescreenconfig:write
GET/v2/screening/internalconfig:read
PUT/v2/screening/internalconfig:write
DELETE/v2/screening/internalconfig:write
GET/v2/analytics/breakdownapplicants:read
GET/v2/analytics/seriesapplicants:read
GET/v2/auditaudit:read
GET/v2/featurespublic
GET/v2/capabilitiespublic
GET/v2/readinessany signed-in user

Alerts and cases

MethodPathRequires
POST/v2/alerts/bulkalerts:write
GET/v2/alertsalerts:read
PATCH/v2/alerts/:idalerts:write
GET/v2/casescases:read
POST/v2/casescases:write
POST/v2/cases/:id/notescases:write

Documents, export and retention

MethodPathRequires
GET/v2/export/audit.csvaudit:read
GET/v2/export/applicants.csvexport
GET/v2/export/warehouse.ndjsonexport
GET/v2/documents/:idapplicants:read
GET/v2/retention/previewconfig:read

Web SDK

One file, no dependencies, no build step. Create the session on your server so the token cannot be minted from your page, then hand it to open():

<script src="https://hawkai.ai-sns.io/assets/js/hawkai-sdk.js"></script>
<script>
  HawkAi.open({
    session: token,          // from POST /v2/verification-sessions
    locale: 'ja',
    onComplete(r) { /* r.status is approved | review | rejected */ },
    onClose() { /* the applicant closed it without finishing */ },
  });
</script>

The flow runs in an iframe on our origin, so the camera permission is granted to us rather than to your site and your page keeps its state. The completion message is checked against our origin before the SDK believes it — without that check any page could post a fake approval into your callback. HawkAi.redirect({ session }) hands over as a full page instead, which is the better choice on a small screen.

iOS and Android

There is no native SDK. Until there is, the supported mobile integration is the hosted flow in a web view — which is what most native SDKs are underneath, and it means the camera permission and the verification logic stay on our side rather than being compiled into your app.

Create the session on your server, open the URL, and watch for the return address you set on the link.

Swift

import WebKit

// url comes from your server: POST /v2/verification-sessions
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []   // the camera step needs this
let web = WKWebView(frame: view.bounds, configuration: config)
web.navigationDelegate = self
web.load(URLRequest(url: url))

// Add NSCameraUsageDescription to Info.plist, or getUserMedia is refused
// without an error the applicant can act on.

func webView(_ w: WKWebView, decidePolicyFor nav: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    if let u = nav.request.url, u.host == myReturnHost {
        let status = URLComponents(url: u, resolvingAgainstBaseURL: false)?
            .queryItems?.first(where: { $0.name == "status" })?.value
        decisionHandler(.cancel)
        finish(status)          // approved | review | rejected
        return
    }
    decisionHandler(.allow)
}

Kotlin

val web = WebView(this)
web.settings.javaScriptEnabled = true
web.settings.domStorageEnabled = true
web.webChromeClient = object : WebChromeClient() {
    // without this the camera step is denied silently
    override fun onPermissionRequest(r: PermissionRequest) = r.grant(r.resources)
}
web.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(v: WebView, req: WebResourceRequest): Boolean {
        val u = req.url
        if (u.host == myReturnHost) { finish(u.getQueryParameter("status")); return true }
        return false
    }
}
web.loadUrl(sessionUrl)   // add android.permission.CAMERA to the manifest

Both need the camera permission declared — an in-app web view is denied getUserMedia by default, and the flow will fall back to the file picker, which works but is slower for the applicant.

Single sign-on

Over OpenID Connect, authorization code with PKCE. GET /v2/auth/sso/status says whether this deployment has an issuer configured — the sign-in page asks before it offers the button, so an unconfigured deployment says so instead of redirecting nowhere. Send an operator to /v2/auth/sso/start; they come back to /v2/auth/sso/callback, and the session is handed to the page in a URL fragment rather than a query string, so it never reaches an access log.

An address your provider vouches for that has no account here is refused rather than created. That is deliberate: an SSO login that provisions its own accounts turns a misconfigured directory into a way in. Ask an administrator to invite the person, or set OIDC_PROVISION_TENANT if you want automatic joining, ideally with OIDC_ALLOWED_DOMAINS alongside it.

Usage and invoices

GET /v2/tenant/usage is a live quote for the current period; GET /v2/tenant/invoices lists one invoice per closed month, each written once. Usage is counted from the applicant records rather than from a counter, because a counter drifts and a billing number that drifts cannot be defended. A verification counts when it reached a decision — which is what the pricing page says.

POST /v2/tenant/plan changes plan. Starter and Growth apply immediately and are invoiced; Enterprise is recorded as a request, because it is a contract rather than a setting. No card is taken anywhere: no payment provider is connected, and holding card details in a system that cannot process them would be worse than invoicing.

Bulk actions

POST /v2/applicants/bulk moves work in one go: assign, escalate, resubmission, note. approve and reject return 422 with the reason — a bulk approve is how one mistake becomes two hundred, and a bulk reject decides against that many people at once. POST /v2/alerts/bulk clears alerts, which are work items rather than decisions; closing them still needs a reason.

Machine-readable specification

The same route table is published as OpenAPI 3.1 at /openapi.json, generated at build time. Point a client generator at it rather than transcribing the table above. Request and response bodies are described where the server validates them and left as open objects where it does not — a specification that invents a schema the server does not enforce is worse than one that admits the gap.

Sandbox and going live

Sandbox uses the same API and the same engine. Going live needs the verification providers connected — until then every decision is a demonstration, and the go-live checklist in your console says so rather than letting you find out later. Current state of this deployment: service status.