npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@asqary/node

v0.2.0

Published

Official Node.js/TypeScript SDK for the Asqary transaction risk decisioning API

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 fetch and node:crypto)
  • Raw customer identifiers are hashed on your server and never sent to Asqary

Contents

  1. Installation
  2. Authentication
  3. Quick start
  4. Transaction scoring
  5. Handling decisions
  6. Risk reasons
  7. Error handling
  8. Policies
  9. Backtesting
  10. Webhooks
  11. Feedback
  12. Timeouts
  13. Retries
  14. Idempotency
  15. Cancellation

Installation

npm install @asqary/node

Pre-release: the package isn't on npm yet. Until it is, install it from the repository or a packed tarball (npm pack in this repo, then npm 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 audit

delete 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) to create or createFromCsv. The email carries a summary and a dashboard link, never transaction data.
  • Webhook: subscribe an endpoint to backtest.completed and backtest.failed (see Webhooks), then fetch the results with backtests.get when 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);
    // ...
  }
});
  • verifyWebhook rejects signatures older than 5 minutes ({ tolerance: seconds } to change). While rotating secrets with asqary.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) and webhooks.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 most maxRetryDelay, 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 call

Retries 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 with replayed: true and 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.ts

License

Proprietary. Copyright © Twalle. All rights reserved. See LICENSE.