Documentation
Quickstart, the verification flow, webhooks and the full API reference.
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.
1. Create a verification link
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.
| Status | Means |
|---|---|
400 | The request is wrong. field names the offending input where we can tell |
401 | No credential, or an expired verification link |
403 | Authenticated, but not permitted — a role or key scope problem. The message says which |
404 | No such record, in your tenant |
409 | A conflict. The most important one: an approval the compliance engine refuses |
429 | Rate limited. retry_after in seconds |
503 | The 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
| Method | Path | Requires |
|---|---|---|
POST | /v2/auth/login | public |
POST | /v2/auth/mfa | public |
POST | /v2/auth/logout | any signed-in user |
GET | /v2/auth/me | platform admin |
POST | /v2/auth/mfa/setup | any signed-in user |
POST | /v2/auth/mfa/confirm | any signed-in user |
POST | /v2/auth/mfa/backup-codes | any signed-in user |
GET | /v2/auth/mfa/backup-codes | any signed-in user |
GET | /v2/auth/sso/status | public |
GET | /v2/auth/sso/start | public |
GET | /v2/auth/sso/callback | public |
POST | /v2/auth/passkey/register/begin | any signed-in user |
POST | /v2/auth/passkey/register/finish | any signed-in user |
GET | /v2/auth/passkeys | any signed-in user |
DELETE | /v2/auth/passkeys/:id | any signed-in user |
POST | /v2/auth/passkey/begin | public |
POST | /v2/auth/passkey/finish | public |
POST | /v2/auth/password | any signed-in user |
POST | /v2/auth/signup | public |
GET | /v2/auth/invite/:token | public |
POST | /v2/auth/invite/accept | public |
POST | /v2/auth/forgot | public |
Applicants and verification
| Method | Path | Requires |
|---|---|---|
GET | /v2/applicants | applicants:read |
GET | /v2/applicants/:id | applicants:read |
POST | /v2/applicants | applicants:write |
POST | /v2/applicants/:id/recheck | applicants:write |
POST | /v2/applicants/:id/decision | applicants:decide |
POST | /v2/applicants/bulk | applicants:decide |
POST | /v2/applicants/:id/endorse | applicants:decide |
POST | /v2/applicants/import | applicants:write |
POST | /v2/applicants/:id/screening/:index | applicants:decide |
POST | /v2/applicants/:id/notes | applicants:read |
GET | /v2/applicants/compare | applicants:read |
POST | /v2/verification-sessions | applicants:write |
GET | /v2/verify/session/:token | public |
POST | /v2/verify/resume | public |
GET | /v2/verify/resume/:token | public |
POST | /v2/verify/document | public |
POST | /v2/verify/submit | public |
POST | /v2/applicants/:id/documents | applicants:write |
POST | /v2/applicants/:id/erase | config:write |
GET | /v2/applicants/:id/data | applicants:read |
PATCH | /v2/applicants/:id | applicants:write |
Client configuration
| Method | Path | Requires |
|---|---|---|
GET | /v2/tenant/usage | config:read |
GET | /v2/tenant/invoices | config:read |
GET | /v2/tenant/invoices/:period | config:read |
POST | /v2/tenant/plan | config:write |
GET | /v2/tenant/suppressions | alerts:read |
POST | /v2/tenant/suppressions | alerts:write |
DELETE | /v2/tenant/suppressions/:id | alerts:write |
GET | /v2/tenant/filters | applicants:read |
POST | /v2/tenant/filters | applicants:read |
DELETE | /v2/tenant/filters/:id | applicants:read |
POST | /v2/tenant/pause | config:write |
POST | /v2/tenant/resume | config:write |
POST | /v2/tenant/rules/simulate | config:read |
POST | /v2/tenants | platform admin |
GET | /v2/tenant/onboarding | config:read |
POST | /v2/tenant/team/:userId/invite | team:write |
GET | /v2/tenant | config:read |
PUT | /v2/tenant/settings | config:write |
GET | /v2/tenant/team | config:read |
POST | /v2/tenant/team | team:write |
GET | /v2/tenant/keys | keys:read |
POST | /v2/tenant/keys | keys:write |
DELETE | /v2/tenant/keys/:id | keys:write |
GET | /v2/tenant/webhooks | webhooks:read |
POST | /v2/tenant/webhooks | webhooks:write |
POST | /v2/tenant/webhooks/:id/ping | webhooks:write |
GET | /v2/tenant/rules | config:read |
GET | /v2/tenant/flows | config:read |
POST | /v2/tenant/branding/logo | config:write |
DELETE | /v2/tenant/branding/logo | config:write |
Platform
| Method | Path | Requires |
|---|---|---|
GET | /v2/plans | public |
GET | /v2/screening/lists | config:read |
POST | /v2/screening/refresh | platform admin |
POST | /v2/screening/rescreen | config:write |
GET | /v2/screening/internal | config:read |
PUT | /v2/screening/internal | config:write |
DELETE | /v2/screening/internal | config:write |
GET | /v2/analytics/breakdown | applicants:read |
GET | /v2/analytics/series | applicants:read |
GET | /v2/audit | audit:read |
GET | /v2/features | public |
GET | /v2/capabilities | public |
GET | /v2/readiness | any signed-in user |
Alerts and cases
| Method | Path | Requires |
|---|---|---|
POST | /v2/alerts/bulk | alerts:write |
GET | /v2/alerts | alerts:read |
PATCH | /v2/alerts/:id | alerts:write |
GET | /v2/cases | cases:read |
POST | /v2/cases | cases:write |
POST | /v2/cases/:id/notes | cases:write |
Documents, export and retention
| Method | Path | Requires |
|---|---|---|
GET | /v2/export/audit.csv | audit:read |
GET | /v2/export/applicants.csv | export |
GET | /v2/export/warehouse.ndjson | export |
GET | /v2/documents/:id | applicants:read |
GET | /v2/retention/preview | config: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.