@asqary/node
v0.2.0
Published
Official Node.js/TypeScript SDK for the Asqary transaction risk decisioning API
Maintainers
Readme
Asqary Node.js SDK
The official Node.js / TypeScript client for Asqary, transaction risk decisioning for digital lenders. Send a transaction, get back a 0–100 risk score, a recommended action and the reasons, under a versioned policy you control.
The SDK is a thin client for the Asqary API. All scoring happens in Asqary; the SDK handles authentication, identifier hashing, retries, timeouts, request IDs and typed errors.
- Node.js 20.3+, TypeScript types included, ESM and CommonJS
- No runtime dependencies (native
fetchandnode:crypto) - Raw customer identifiers are hashed on your server and never sent to Asqary
Contents
- Installation
- Authentication
- Quick start
- Transaction scoring
- Handling decisions
- Risk reasons
- Error handling
- Policies
- Backtesting
- Webhooks
- Feedback
- Timeouts
- Retries
- Idempotency
- Cancellation
Installation
npm install @asqary/nodePre-release: the package isn't on npm yet. Until it is, install it from the repository or a packed tarball (
npm packin this repo, thennpm install ./twalle-asqary-0.1.0.tgz).
Authentication
You need two secrets, both from your Asqary setup, and both kept on your server:
| Setting | Environment variable | What it is |
| --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
| apiKey | ASQARY_API_KEY | Your API key from the Asqary dashboard. asq_test_… keys use test data; asq_live_… keys use live data. |
| identifierKey | ASQARY_IDENTIFIER_KEY | Your key for hashing customer identifiers. You generate and keep it; Asqary never sees it. |
Generate an identifier key once and store it in your secrets manager:
openssl rand -hex 32
# or: node -e "console.log(require('@asqary/node').Asqary.generateIdentifierKey())"The SDK hashes identifiers (customer ID, BVN, phone, device ID, beneficiary account) with HMAC-SHA256 under this key before sending them, so Asqary can recognise the same customer across transactions without ever receiving the raw value. Keys must be 32–64 random bytes in hex or base64; weak keys are rejected at startup.
- Use the same key everywhere — in every service that calls Asqary, and in the dashboard when you upload a backtest CSV — or the same customer will look like different people.
- Don't change it. A new key gives every customer new hashes, so their behavioural history starts over.
- Normalise identifiers before passing them (e.g. phone numbers in E.164,
+2348012345678). Only surrounding whitespace is removed for you.
Quick start
import { Asqary } from "@asqary/node";
const asqary = new Asqary(); // reads ASQARY_API_KEY and ASQARY_IDENTIFIER_KEY
const decision = await asqary.score({
transactionId: "txn_123",
type: "loan_disbursement",
amount: 150_000, // minor units: ₦1,500.00
currency: "NGN",
occurredAt: new Date(),
customerId: "customer_456",
deviceId: "device_789",
beneficiaryAccount: "0123456789",
beneficiaryBankCode: "058",
});
console.log(decision.score, decision.riskLevel, decision.recommendedAction);The client defaults to the Asqary staging API (https://api-staging.asqary.com) until production is live. Set baseUrl to target another environment.
const asqary = new Asqary({
apiKey: process.env.ASQARY_API_KEY,
identifierKey: process.env.ASQARY_IDENTIFIER_KEY,
baseUrl: "https://api-staging.asqary.com",
timeout: 30_000, // ms per attempt
maxRetries: 2,
});Create one client and share it; it is safe for concurrent use.
Transaction scoring
asqary.score(transaction) accepts:
| Field | Required | Notes |
| ---------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| transactionId | yes | Your ID (1–128 of A-Za-z0-9._:-). Also the idempotency key. |
| type | yes | loan_application, loan_disbursement, repayment, transfer, withdrawal, deposit, card_payment, bill_payment, other |
| amount | yes | Integer in minor units (kobo). |
| currency | yes | ISO 4217; must match your market (NGN). |
| occurredAt | yes | Date or ISO string. |
| customerId / customerIdHash | yes | Raw (hashed for you) or already hashed. |
| bvn / bvnHash, phone / phoneHash | no | |
| deviceId / deviceIdHash, devicePlatform | no | Needed for new-device checks. |
| beneficiaryAccount / beneficiaryAccountHash, beneficiaryBankCode | no | Needed for new-beneficiary checks. |
| country, region | no | e.g. "NG", "Lagos" |
| channel | no | e.g. "mobile_app" |
| product | no | Uses that product's policy if you have one. |
| policyVersionId | no | Test keys only: score against a specific (e.g. draft) policy version. |
Give each identifier either raw or hashed, never both. If you hash identifiers yourself, use HMAC-SHA256 with your identifier key over the trimmed UTF-8 value and pass the lowercase hex as ...Hash.
Handling decisions
switch (decision.recommendedAction) {
case "approve":
return approve();
case "step_up":
return requestAdditionalVerification();
case "manual_review":
return sendToReviewQueue(decision.decisionId);
case "block":
return decline();
}A decision contains:
| Field | Meaning |
| -------------------------------------------------- | -------------------------------------------------------------------------------- |
| score | 0–100: how anomalous the transaction looks |
| riskLevel | low, medium, high or critical, from your policy's thresholds |
| confidence | 0–100: how much history backs the score; new customers score with low confidence |
| recommendedAction | your policy's action for this risk level |
| reasons | the rules that raised the score (below) |
| policy | { id, version, versionId } — the exact policy version used |
| decisionId, requestId, createdAt, replayed | for audit, support and idempotency |
Store decisionId with your transaction so you can look it up (asqary.decisions.get(id)) and report its outcome later.
Risk reasons
for (const reason of decision.reasons) {
console.log(`${reason.code} +${reason.contribution}: ${reason.message}`);
// amount_anomaly +25: Amount is 10.0× the customer's typical amount
}Each reason has a code (the rule), contribution (score points it added), a human-readable message and details with the evidence (e.g. { ratio: 10, baseline_amount: 100000 }). Reasons are sorted by contribution. For every rule's full evaluation, including rules that didn't fire, fetch the decision:
const detail = await asqary.decisions.get(decision.decisionId);
detail.evaluations; // [{ ruleCode, signal, coverage, weight, contribution, message, details }]Error handling
Every error is an AsqaryError with code, message, statusCode, requestId (quote it to support), details and retryable.
import { AsqaryError, RateLimitError, ValidationError } from "@asqary/node";
try {
await asqary.score(transaction);
} catch (error) {
if (error instanceof ValidationError) {
console.error(error.fieldErrors); // [{ field: "transaction.currency", message: "…" }]
} else if (error instanceof RateLimitError) {
console.error(`rate limited; retry in ${error.retryAfter}s`);
} else if (error instanceof AsqaryError) {
console.error(error.code, error.message, error.statusCode, error.requestId);
}
throw error;
}| Class | When |
| ---------------------------------------- | ------------------------------------------------------------ |
| AsqaryConfigError | bad client configuration (API key, identifier key, base URL) |
| AsqaryInputError | invalid input caught before sending |
| InvalidRequestError | 400 |
| AuthenticationError | 401: invalid, expired or revoked API key |
| PermissionError / QuotaExceededError | 403: missing scope or role / monthly quota used up |
| NotFoundError | 404 |
| ConflictError | 409: e.g. policy_in_use, idempotency_key_reused |
| PayloadTooLargeError | 413: e.g. backtest over the limits |
| ValidationError | 422: see fieldErrors |
| RateLimitError | 429: see retryAfter |
| ServerError | 5xx |
| ConnectionError, TimeoutError | no response / took too long |
Errors never include your API key, identifier key or transaction data.
Policies
Policies decide which rules run, their weights and parameters, the score thresholds for each risk level, and the action for each level. Scoring always uses the active version; versions are immutable once used, so past decisions stay reproducible.
const rules = await asqary.rules.list(); // available rules, defaults and parameters
const policy = await asqary.policies.create({
name: "BNPL checkout",
product: "bnpl", // optional: applies only to this product
rules: [
{ code: "transaction_velocity", weight: 20, config: { window_minutes: 10, threshold: 5 } },
{ code: "amount_anomaly", weight: 20, config: { baseline_multiplier: 3 } },
],
thresholds: { medium: 30, high: 60, critical: 80 },
actions: { low: "approve", medium: "approve", high: "manual_review", critical: "block" },
}); // created as a draft
await asqary.policies.activate(policy.policyId); // now scores live traffic
await asqary.policies.update(policy.policyId, { thresholds: { critical: 90 } }); // creates a new draft version
await asqary.policies.activate(policy.policyId, { version: 2 });
await asqary.policies.list();
await asqary.policies.get(policy.policyId); // with all versions
await asqary.policies.getVersion(policy.policyId, 1);
await asqary.policies.archive(policy.policyId); // retire; versions stay for auditdelete only works for policies that were never tested, activated or used.
Backtesting
Test a policy version on historical transactions before activating it. Backtests always name the exact version they evaluate and never affect live data.
const backtest = await asqary.backtests.create({
policyVersionId: policy.versions![0]!.versionId,
transactions: history.map((t) => ({
transactionId: t.id,
type: "loan_disbursement",
amount: t.amountKobo,
currency: "NGN",
occurredAt: t.createdAt,
customerId: t.customerId, // hashed locally, as in score()
deviceId: t.deviceId,
label: t.wasFraud ? "fraud" : "legitimate", // optional, enables precision/recall
})),
});
const result = await asqary.backtests.waitForCompletion(backtest.backtestId);
console.log(result.summary?.byRiskLevel, result.summary?.labels?.thresholds);
for await (const row of asqary.backtests.rows(backtest.backtestId)) {
// row.score, row.riskLevel, row.reasons, row.error
}A backtest can hold at most 50,000 transactions and 25 MB; split larger datasets by date range. You can also upload a CSV whose identifier columns are already hashed with asqary.backtests.createFromCsv(csv, { policyVersionId }).
Backtests of large files can take a while. Instead of keeping a process waiting, you can have Asqary tell you when one is done:
- Email: pass
notifyEmails: ["[email protected]"](up to 10 members of your organisation) tocreateorcreateFromCsv. The email carries a summary and a dashboard link, never transaction data. - Webhook: subscribe an endpoint to
backtest.completedandbacktest.failed(see Webhooks), then fetch the results withbacktests.getwhen the event arrives.
Webhooks
Asqary POSTs signed events to your HTTPS endpoints: backtest.completed, backtest.failed, policy.activated and policy.archived. Endpoints belong to the API key's environment (live or test), and the key needs the webhooks:manage scope to change them.
const webhook = await asqary.webhooks.create({
url: "https://lender.example.com/asqary/webhooks",
events: ["backtest.completed", "backtest.failed"], // or ["*"] for everything
});
// Store webhook.secret (whsec_...) now: it is never returned again.
await asqary.webhooks.sendTest(webhook.webhookId);Verify every delivery with verifyWebhook before trusting it. It needs the raw request body, since parsing and re-serialising the JSON changes the bytes:
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@asqary/node";
const app = express();
app.post("/asqary/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.header("Asqary-Signature"), process.env.ASQARY_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(400);
throw err;
}
res.sendStatus(200); // respond within 10 seconds; do slow work afterwards
if (event.type === "backtest.completed") {
const backtest = await asqary.backtests.get(event.data.backtestId);
// ...
}
});verifyWebhookrejects signatures older than 5 minutes ({ tolerance: seconds }to change). While rotating secrets withasqary.webhooks.rotateSecret(id), pass both:verifyWebhook(body, header, [newSecret, oldSecret]).- The same event can be delivered more than once, and events can arrive out of order: deduplicate on
event.id. - Failed deliveries (anything but a 2xx, including redirects) are retried after 1 min, 5 min, 30 min, 2 h, 5 h, then every 10 h, for up to 3 days. An endpoint that fails for 3 days is disabled; re-enable it with
webhooks.update(id, { status: "enabled" }). - Inspect and replay deliveries with
webhooks.listDeliveries(id),webhooks.getDelivery(id, deliveryId)andwebhooks.resendDelivery(id, deliveryId).
Feedback
Report what actually happened so policies can be measured:
await asqary.feedback.create({
decisionId: decision.decisionId, // or transactionId
label: "fraud", // "fraud" | "legitimate" | "suspected"
fraudType: "account_takeover", // optional
outcomeAt: new Date(),
});Timeouts
Each attempt times out after 30 seconds by default. Change it for the client or per call:
const asqary = new Asqary({ timeout: 5_000 });
await asqary.score(transaction, { timeout: 2_000 });A timeout raises TimeoutError (after any retries).
Retries
The SDK retries transient failures up to maxRetries times (default 2) with exponential backoff and jitter:
- Always retried: 429 (after
Retry-After, if it's at mostmaxRetryDelay, default 10 s), 502 and 503. - Retried when the call is safe to repeat: network errors, timeouts, 500 and 504. Every call is safe to repeat except
policies.delete. - Never retried: 400, 401, 403, 404, 409, 413 and 422.
const asqary = new Asqary({ maxRetries: 4 });
await asqary.rules.list({ maxRetries: 0 }); // per callRetries reuse the same request ID and idempotency key.
Idempotency
Retrying never creates duplicates:
- Scoring is idempotent on
transactionId: scoring the same ID again returns the original decision withreplayed: trueand doesn't count the transaction twice. - Creating policies, backtests and feedback sends an
Idempotency-Key. The SDK generates one per call, so its own retries are safe. To make retries across restarts or processes safe, pass your own:
await asqary.policies.create(input, { idempotencyKey: `policy-${changeRequestId}` });
await asqary.feedback.create(input, { idempotencyKey: `outcome-${loanId}` });Reusing a key with different content raises ConflictError (idempotency_key_reused).
Cancellation
Pass an AbortSignal to cancel a request, including any pending retry:
const controller = new AbortController();
setTimeout(() => controller.abort(), 1_000);
await asqary.score(transaction, { signal: controller.signal });
// rejects with the signal's reason when aborted
await asqary.backtests.waitForCompletion(id, { signal: AbortSignal.timeout(60_000) });Request IDs
Every call has a request ID, sent as X-Request-Id and echoed by the API. It is on every decision (decision.requestId), feedback result and error (error.requestId). Pass your own to correlate with your logs:
await asqary.score(transaction, { requestId: `checkout-${orderId}` });Privacy and security
- API keys, identifier keys and payloads are never logged or included in errors.
- Raw identifiers are hashed in your process; only hashes are sent.
- HTTPS is required (plain HTTP is allowed only for
localhost). - The SDK sends no telemetry and stores nothing on disk.
Development
npm install
npm run validate # format, lint, typecheck, tests, build, package checks
npm run test:integration # against a running Asqary API; see test/integration/api.test.tsLicense
Proprietary. Copyright © Twalle. All rights reserved. See LICENSE.
