Nuba Verify API
The Nuba Verify API lets you run fully automated tenant verification from your own product. It handles document parsing, address validation, Open Banking transaction matching and landlord verification — returning a single weighted score and a pass/fail outcome for each tenancy.
JSON REST API
All requests and responses use application/json
Webhook delivery
Events pushed to your endpoint on session completion
HMAC signatures
Every webhook is signed with SHA-256 for authenticity
Authentication
All partner endpoints require an API key passed in the x-api-key request header. Keys are prefixed with nuba_sk_ and are created in your partner dashboard.
sessionId to the browser.POST /v1/verify/sessions HTTP/1.1
Host: verify-api.nubarewards.com
Content-Type: application/json
x-api-key: nuba_sk_<your_key>Base URL
Production: https://verify-api.nubarewards.com
Sandbox: use the Sandbox runner in your partner dashboardThere is no separate sandbox host. Use the Sandbox tab in your dashboard to trigger test sessions with pre-seeded data.
Types & Enums
SessionStatus
The current state of a verification session.
type SessionStatus =
| 'PENDING' // Session created; awaiting document upload
| 'EXTRACTING' // Document uploaded; AI extraction in progress
| 'PROCESSING' // Tenant confirmed details; all layers running
| 'PASSED' // Score ≥ partner's pass mark threshold
| 'MANUAL_REVIEW' // Score between review and pass mark thresholds
| 'FAILED' // Score < partner's manual review thresholdLayerStatus
type LayerStatus =
| 'PENDING' // Layer not yet started
| 'RUNNING' // Layer job in progress
| 'EXTRACTED' // Document layer: extraction complete, awaiting confirm
| 'COMPLETE' // Layer finished successfully with a score
| 'FAILED' // Layer failed (score is 0 or null)
| 'SKIPPED' // Layer intentionally bypassed (e.g. Open Banking when
// financialVerificationEnabled = false on the partner)LayerType
type LayerType =
| 'DOCUMENT' // Layer 1 — AI document parsing & tampering detection (35%)
| 'ADDRESS' // Layer 2 — Address & rent benchmarking (25%)
| 'OPEN_BANKING' // Layer 3 — Bank transaction matching via TrueLayer (25%)
| 'LANDLORD' // Layer 4 — Landlord/agent entity verification (15%)CreateSessionRequest
interface CreateSessionRequest {
tenantName: string; // Full name of the tenant
tenantEmail: string; // Tenant's email address
webhookUrl?: string; // URL to receive completion events (optional)
redirectUrl?: string; // Where to redirect after wizard completes (optional)
// ── BYO (Bring Your Own) banking data — added v1.5 ──────────────
// Partners that already hold tenant transaction history can submit
// it here instead of redirecting the tenant through TrueLayer.
// Requires byoEnabled = true on your partner config; see the
// Verification Config tab in your dashboard.
transactionStream?: TransactionStream; // Pre-collected bank data
byoMode?: 'fallback' | 'strict'; // Per-session override
}
interface TransactionStream {
source: string; // Free-text source label (e.g. "Plaid", "in-house")
attestedAt: string; // ISO 8601 — when the data was collected
windowMonths: number; // How many months of history are included (1–24)
account: {
sortCode: string; // 6 digits
accountNumber: string; // 8 digits
accountName?: string;
};
transactions: Array<{
date: string; // ISO 8601
amount: number; // GBP, negative for outgoing
description: string;
reference?: string;
}>;
}CreateSessionResponse
interface CreateSessionResponse {
sessionId: string; // UUID — use in all subsequent calls
status: SessionStatus; // Always 'PENDING' on creation
verificationUrl: string; // Tenant-facing wizard URL
// Present only when transactionStream was supplied on the request.
byo?: {
accepted: boolean; // false ⇒ stream rejected, see reason
reason?: string; // Why the stream was rejected (strict mode)
mode: 'fallback' | 'strict'; // Effective mode used for this session
};
}ExtractedData
interface ExtractedData {
tenantName: string;
landlordName: string;
landlordEntityType: 'individual' | 'company';
address: string; // Full address string from the document
rentAmount: number; // Monthly rent in GBP
tenancyStartDate: string; // ISO 8601 date
bankSortCode: string | null;
bankAccountNumber: string | null;
paymentReference: string | null;
bedrooms: number | null;
tamperingFlags: string[]; // List of detected anomaly codes
documentConfidence: number; // 0–1 extraction confidence
}ConfirmDetailsRequest
interface ConfirmDetailsRequest {
tenantName?: string;
addressLine1: string; // required
addressLine2?: string;
city: string; // required
postcode: string; // required
propertyType: 'residential' | 'commercial'; // required
bedrooms?: number; // required for residential
furnished: boolean; // required
rentAmount: number; // required — monthly GBP
rentDueDate: number; // required — day of month 1–31
landlordName: string; // required
landlordType: 'individual' | 'company'; // required
landlordEmail?: string;
accountName: string; // required — name on rent-receiving bank account
landlordSortCode: string; // required — 6 digits
landlordAccountNumber: string; // required — 8 digits
paymentReference?: string;
tenancyStartDate?: string; // ISO 8601
tenancyEndDate?: string; // ISO 8601; omit for rolling tenancies
}SessionDetails
interface SessionDetails {
sessionId: string;
status: SessionStatus;
totalScore: number | null; // 0–100; null while PENDING/EXTRACTING
createdAt: string; // ISO 8601
updatedAt: string;
tenant: {
name: string | null;
email: string | null;
};
property: {
addressLine1: string | null;
addressLine2: string | null;
city: string | null;
postcode: string | null;
propertyType: 'residential' | 'commercial';
bedrooms: number | null;
furnished: boolean | null;
};
tenancy: {
rentAmount: number | null;
rentDueDate: number | null; // day of month
startDate: string | null;
endDate: string | null;
};
rentRecipient: {
accountName: string | null;
sortCode: string | null;
accountNumber: string | null;
paymentReference: string | null;
};
landlord: {
name: string | null;
type: 'individual' | 'company' | null;
email: string | null;
};
document: {
url: string | null; // Presigned URL, expires in 1 hour
mimeType: string | null;
fileSize: number | null; // bytes
confidence: number | null; // 0–1
tamperingFlags: string[];
};
verificationSummary: string | null; // AI-generated plain-English summary
}WebhookPayload
Discriminate the event type by the status field (PASSED / MANUAL_REVIEW / FAILED). The dispatch timestamp is delivered in the x-nuba-timestamp header alongside the signature, not inside the body.
interface WebhookPayload {
sessionId: string;
status: SessionStatus; // PASSED | MANUAL_REVIEW | FAILED
totalScore: number;
breakdown: {
document: { score: number; flags: string[] };
address: { score: number; propertyConfirmed: boolean; rentRatio: number };
openBanking: { score: number; consecutiveMonthsFound: number; accountMatchConfirmed: boolean };
landlord: { score: number; entityType: 'individual' | 'company'; networkMatch: boolean };
};
summary: string | null; // AI-generated plain-English summary
}Endpoints
POST /v1/verify/sessions
Creates a new verification session. Returns a verificationUrl to send to the tenant (or open in the embedded SDK), and a sessionId for all subsequent calls. Requires x-api-key.
Request body
| Field | Type | Description |
|---|---|---|
tenantNamereq | string | Full legal name of the tenant. |
tenantEmailreq | string | Tenant's email address. Used to send the verification link. |
webhookUrl | string | URL to receive session completion events. Can also be configured in the dashboard. |
redirectUrl | string | Where the wizard redirects after completion (non-embedded flows). Defaults to the request origin. |
transactionStream | TransactionStream | Pre-collected bank transaction history. Skips the TrueLayer step. Requires byoEnabled = true on your partner config. See the TransactionStream type. |
byoMode | 'fallback' | 'strict' | Per-session override of your default BYO mode. 'fallback' falls back to TrueLayer if the stream is invalid; 'strict' rejects the request with 422 instead. |
// Response — 201 Created
{
"sessionId": "e324ad63-1018-4f42-a0ae-db764682f5a6",
"status": "PENDING",
"verificationUrl": "https://verify.nubarewards.com/verify/e324ad63-..."
}
// Response — 201 Created (with BYO transaction stream)
{
"sessionId": "e324ad63-...",
"status": "PENDING",
"verificationUrl": "https://verify.nubarewards.com/verify/e324ad63-...",
"byo": {
"accepted": true,
"mode": "fallback"
}
}
// Response — 422 (BYO rejected, strict mode)
{
"error": "BYO_VALIDATION_FAILED",
"message": "transactionStream.windowMonths must be ≥ 6 for strict mode"
}POST /v1/verify/sessions/:sessionId/document
Uploads the tenancy agreement (as a Base64-encoded string). Triggers Layer 1 extraction. The wizard handles this step automatically when tenants upload their document. No x-api-key required — the session ID acts as the credential.
| Field | Type | Description |
|---|---|---|
documentBase64req | string | Base64-encoded PDF or image of the tenancy agreement. |
// Response — 202 Accepted
{
"sessionId": "e324ad63-...",
"status": "EXTRACTING",
"message": "Document uploaded. Extraction in progress."
}GET /v1/verify/sessions/:sessionId/extracted
Returns the AI-extracted data from Layer 1. Poll every 2 seconds until ready: true. No auth required — safe to call from the tenant's browser.
// Response — extraction complete
{
"status": "EXTRACTED",
"ready": true,
"extracted": {
"tenantName": "Jane Smith",
"landlordName": "Acme Properties Ltd",
"landlordEntityType": "company",
"address": "42 Victoria Road, Manchester, M1 2JB",
"rentAmount": 1200,
"tenancyStartDate": "2025-01-15",
"bankSortCode": "112233",
"bankAccountNumber": "87654321",
"paymentReference": "SMITH42",
"bedrooms": 2,
"tamperingFlags": [],
"documentConfidence": 0.97
}
}
// Response — still processing
{
"status": "RUNNING",
"ready": false,
"extracted": null
}POST /v1/verify/sessions/:sessionId/confirm
Submits the tenant-confirmed (and optionally corrected) details. Runs Layer 1 cross-checks, then kicks off Layers 2 and 4 in parallel. Returns a TrueLayer auth URL for Layer 3 (Open Banking). No auth required.
Request body — see ConfirmDetailsRequest
// Response — 200 OK
{
"sessionId": "e324ad63-...",
"status": "PROCESSING",
"truelayerAuthUrl": "https://auth.truelayer.com/?...",
"message": "Details confirmed. Verification in progress."
}GET /v1/verify/:sessionId
Public status endpoint. Returns real-time session state and per-layer progress. No auth required — poll from the tenant's browser or your own dashboard.
// Response — session in progress
{
"sessionId": "e324ad63-...",
"status": "PROCESSING",
"totalScore": null,
"layers": {
"document": { "status": "COMPLETE", "score": 88 },
"address": { "status": "COMPLETE", "score": 75 },
"openBanking": { "status": "RUNNING", "score": null },
"landlord": { "status": "RUNNING", "score": null }
},
"theme": {
"primaryColor": "#E8A23A",
"partnerName": "Acme Rentals",
"partnerLogoUrl": "https://..."
}
}
// Response — session complete
{
"sessionId": "e324ad63-...",
"status": "PASSED",
"totalScore": 82.5,
"layers": {
"document": { "status": "COMPLETE", "score": 88 },
"address": { "status": "COMPLETE", "score": 75 },
"openBanking": { "status": "COMPLETE", "score": 91 },
"landlord": { "status": "COMPLETE", "score": 70 }
},
"breakdown": {
"document": { "score": 88, "flags": [] },
"address": { "score": 75, "propertyConfirmed": true, "rentRatio": 1.1 },
"openBanking": { "score": 91, "consecutiveMonthsFound": 6, "accountMatchConfirmed": true },
"landlord": { "score": 70, "entityType": "company", "networkMatch": true }
}
}GET /v1/verify/sessions/:sessionId/details
Returns the full session result including all structured fields, scores, document URL and a plain-English AI summary. Requires x-api-key. The API key must belong to the partner that owns the session.
// Response — 200 OK
{
"sessionId": "e324ad63-...",
"status": "PASSED",
"totalScore": 82.5,
"createdAt": "2026-04-17T09:12:00.000Z",
"updatedAt": "2026-04-17T09:14:31.000Z",
"tenant": { "name": "Jane Smith", "email": "jane@example.com" },
"property": {
"addressLine1": "42 Victoria Road",
"addressLine2": null,
"city": "Manchester",
"postcode": "M1 2JB",
"propertyType": "residential",
"bedrooms": 2,
"furnished": true
},
"tenancy": {
"rentAmount": 1200,
"rentDueDate": 1,
"startDate": "2025-01-15",
"endDate": "2026-01-14"
},
"rentRecipient": {
"accountName": "Acme Properties Ltd",
"sortCode": "112233",
"accountNumber": "87654321",
"paymentReference": "SMITH42"
},
"landlord": {
"name": "Acme Properties Ltd",
"type": "company",
"email": "info@acmeprops.co.uk"
},
"document": {
"url": "https://verify-api.nubarewards.com/...",
"mimeType": "application/pdf",
"fileSize": 204800,
"confidence": 0.97,
"tamperingFlags": []
},
"verificationSummary": "The tenant has provided a valid tenancy agreement ..."
}POST /v1/verify — Legacy
Single-request verification — all data submitted in one payload. Requires x-api-key. Returns a TrueLayer auth URL for Open Banking immediately.
// Request body
interface LegacyVerifyRequest {
tenantName: string;
tenantEmail: string;
documentBase64: string;
addressLine1: string;
city: string;
postcode: string;
bedrooms?: number;
furnished: boolean;
rentAmount: number;
landlordName: string;
landlordEmail?: string;
landlordSortCode: string;
landlordAccountNumber: string;
webhookUrl?: string;
}// Response — 202 Accepted
{
"sessionId": "e324ad63-...",
"truelayerAuthUrl": "https://auth.truelayer.com/?..."
}Recurring Payment Confirmation
Once a session reaches PASSED or MANUAL_REVIEW, you can submit ongoing transaction data and have Nuba identify which transactions are the verified rent payment for that tenancy. Each match emits a zero-PII payment.confirmed webhook — useful for triggering rewards, on-time-payment reporting, or rent-financing actions without re-implementing matching logic on your side.
Submission is idempotent — re-POSTing overlapping windows never double-confirms or double-bills. Sessions in FAILED state reject transaction submissions with HTTP 409 (no verified rent contract to match against).
POST /v1/verify/sessions/:sessionId/transactions
Submits one or more transactions for matching against the verified tenancy. Synchronous for batches ≤ 100 transactions; queued (returns 202) above that or when ?async=true is passed. Requires x-api-key; the key must own the session.
Request body
interface SubmitTransactionsRequest {
transactions: Array<{
transactionId?: string; // Your internal ID — used as idempotency key if present
date: string; // ISO 8601
amountPence: number; // Negative for outgoing (tenant → landlord)
description: string;
reference?: string;
counterpartySortCode?: string; // Optional but improves match confidence
counterpartyAccountNumber?: string; // Optional but improves match confidence
}>;
}// Response — 200 OK (synchronous)
{
"sessionId": "e324ad63-...",
"received": 4,
"matched": 1,
"matches": [
{
"transactionId": "boogi_txn_8829",
"matchId": "pm_a3f2...",
"paidOnDate": "2026-04-21",
"amountPence": 120000,
"matchedReference": "SMITH42",
"confidence": 0.94,
"billingPeriod": "2026-04"
}
],
"duplicates": 0
}
// Response — 202 Accepted (async or batch > 100)
{
"jobId": "pmscan_...",
"received": 428,
"statusUrl": "/v1/verify/sessions/e324ad63-.../transactions/pmscan_..."
}transactionId, or — if absent — a hash of (sessionId, date, amountPence, normalisedReference). Re-submissions of the same logical transaction increment the duplicates counter and do not emit a webhook or incur a billing event.GET /v1/verify/sessions/:sessionId/payments
Lists all confirmed payment matches for a session, newest first. Supports ?from/?to (ISO dates) and cursor pagination via ?cursor. Requires x-api-key.
// Response — 200 OK
{
"sessionId": "e324ad63-...",
"matches": [
{
"matchId": "pm_a3f2...",
"paidOnDate": "2026-04-21",
"amountPence": 120000,
"matchedReference": "SMITH42",
"confidence": 0.94,
"billingPeriod": "2026-04",
"createdAt": "2026-04-21T18:32:11.000Z"
},
...
],
"nextCursor": null
}GET /v1/verify/sessions/:sessionId/transactions/:jobId
Returns the status of an async match job. Poll until status is COMPLETE or FAILED.
// Response — job complete
{
"jobId": "pmscan_...",
"status": "COMPLETE",
"matched": 12,
"matches": [ /* same shape as synchronous response */ ],
"duplicates": 3
}payment.confirmed webhook
Delivered (signed with x-nuba-signature) for every match with confidence ≥ 0.60. Dispatched to your configured webhook URL using the same retry policy as session webhooks (2 retries, exponential back-off).
{
"event": "payment.confirmed",
"sessionId": "e324ad63-...",
"matchId": "pm_a3f2...",
"paidOnDate": "2026-04-21",
"amountPence": 120000,
"matchedReference": "SMITH42",
"confidence": 0.94,
"billingPeriod": "2026-04"
}event discriminator. The existing session webhooks discriminate via status. Your handler should branch on whichever field is present — see the verification example in the Webhooks section.tenancy.rentAmount, the amountPence field is omitted from the payload. Handle the field as optional in your integration.Webhooks
Nuba delivers a POST request to your configured webhook URL when a session reaches a terminal state. Configure the URL from the Integration tab in your dashboard.
Events
| Event | Triggered when |
|---|---|
session.passed | totalScore ≥ partner's pass mark threshold (default 70) |
session.manual_review | totalScore between review and pass mark thresholds |
session.failed | totalScore < partner's manual review threshold (default 50) |
Signature verification
Each delivery carries two headers:
x-nuba-timestamp— Unix seconds at dispatch.x-nuba-signature— HMAC-SHA256 of`${timestamp}.${rawBody}`, formatted ast=<timestamp>,v1=<hex>.
x-nuba-signature against the raw request body (bytes, not parsed JSON) before trusting the event. Reject any delivery whose timestamp is more than 5 minutes old to prevent replay attacks.import { createHmac, timingSafeEqual } from 'crypto';
function verifySignature(rawBody: string, header: string, secret: string): boolean {
// Header format: "t=1734567890,v1=abc123…"
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=') as [string, string]),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject stale deliveries (replay protection).
const ageSeconds = Math.floor(Date.now() / 1000) - Number(t);
if (Number.isNaN(ageSeconds) || ageSeconds > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// Constant-time comparison.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
// Express (must use express.raw, not express.json)
app.post('/webhooks/nuba', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-nuba-signature'] as string;
if (!verifySignature(req.body.toString(), sig, process.env.NUBA_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body) as WebhookPayload;
switch (event.status) {
case 'PASSED':
await onPassed(event);
break;
case 'MANUAL_REVIEW':
await flagForReview(event);
break;
case 'FAILED':
await onFailed(event);
break;
}
res.status(200).json({ received: true });
});Retries
If your endpoint does not return a 2xx response within 10 seconds, Nuba retries up to two more times with exponential back-off (3 attempts total). After the final failed attempt the delivery is marked as failed and surfaced under Webhook deliveries in your dashboard, where it can be inspected and re-sent manually.
| Attempt | Delay after previous |
|---|---|
| 1 (initial) | — |
| 2 | ~30 seconds |
| 3 | ~5 minutes |
Back-off is approximate (Bull queue exponential strategy) and may vary by a few seconds.
Errors
All error responses share the same shape. HTTP status codes follow standard conventions.
interface ErrorResponse {
error: string; // Short machine-readable message
details?: Record<string, string[]>; // Per-field validation errors (400 only)
message?: string; // Additional context
}| Status | Meaning |
|---|---|
400 | Validation error — check the details field for per-field messages |
401 | Missing or invalid API key |
404 | Session not found (or owned by a different partner) |
409 | Conflict — e.g. document already confirmed, or session in wrong state |
422 | BYO transaction stream rejected (strict mode) — see message field for the specific validation failure |
429 | Document extraction quota exceeded for the billing period |
500 | Internal server error — contact support if it persists |
502 | Upstream dependency error (TrueLayer, AI provider) |
Changelog
2026-04-22
- Recurring Payment Confirmation API — POST /v1/verify/sessions/:id/transactions to submit ongoing transaction data against a verified session.
- GET /v1/verify/sessions/:id/payments — list confirmed payment matches for a session.
- New webhook event: payment.confirmed. First event to use a top-level `event` discriminator (existing session webhooks continue to discriminate via `status`).
- Idempotent ingestion via partner-supplied transactionId or (sessionId, date, amountPence, reference) hash — re-POSTs never double-confirm or double-bill.
- Currently in implementation; contract is committed. Reach out for sandbox access during the pilot window.
2026-04-21
- BYO (Bring Your Own) banking data — partners with their own transaction history can now submit a transactionStream on POST /v1/verify/sessions and skip the TrueLayer redirect. Gated by partner-level byoEnabled and byoMode (fallback | strict).
- New 422 response on session create when a BYO stream fails strict-mode validation.
- LayerStatus enum extended with SKIPPED — used when a layer is intentionally bypassed (e.g. Open Banking with financialVerificationEnabled = false).
- Disclosure scope — partners can now hide individual fields from the GET /sessions/:id/details response and the webhook payload via the dashboard. Hidden fields are silently omitted (no placeholder).
- WebhookPayload shape clarified: discriminate on `status`, not a top-level `event` field. Dispatch timestamp is delivered in the x-nuba-timestamp header.
- Webhook signature header format documented: t=<unix>,v1=<sha256-hex> over `${timestamp}.${rawBody}`. The legacy `sha256=<hex>` example was incorrect.
- Webhook retry policy corrected: 2 retries (3 attempts total), exponential back-off; failed deliveries surface in the dashboard for manual re-send.
- Webhook payload now includes a `summary` field (AI-generated plain-English overview) at the top level.
2026-04-17
- Partner-configurable pass mark and manual review thresholds (PUT /partner/scoring)
- GET /v1/verify/sessions/:sessionId/details now returns verificationSummary field
- accountName field added to ConfirmDetailsRequest and SessionDetails.rentRecipient
- paymentReference now surfaced on GET /extracted and pre-filled in the wizard
2026-04-10
- Multi-step flow promoted to recommended integration path
- Document re-upload now permitted while session is in EXTRACTING state
- Webhook deliveries now include full score breakdown in payload
- Presigned document URLs delivered via /details endpoint (expire 1 h)
2026-03-01
- POST /v1/verify/sessions/:id/confirm — new confirm endpoint replaces body-only flow
- GET /v1/verify/sessions/:id/extracted — polling endpoint for extraction status
- Partner theming applied to wizard (logo, primary colour)
2026-01-15
- Initial release — POST /verify legacy endpoint
- Webhook delivery with HMAC-SHA256 signing
- Four-layer scoring engine (document 35%, address 25%, open banking 25%, landlord 15%)