Docs

Connecting the SDK to external data sources

UK age verification for the Online Safety Act era: connect the AffixIO Node.js SDK (affixio v1.3.3) to the database or API you already run, check 13+, 16+, or 18+ with an exact yes or no decision, and keep a signed, post-quantum proof. Concepts first, then copyable code examples.

What this capability is

The SDK supports a pattern that can be summed up as data check then prove. It looks up a record from an external source, compares one field in that record against a value you require, and if the two match it emits a signed yes or no proof tied to that lookup. The decision is binary and exact. There is no scoring, no partial match, and no interpretation beyond whether the two values are the same.

import { AffixSDK, openDataStore } from "affixio";

const sdk = new AffixSDK({ apiKey: process.env.AFFIX_API_KEY });
const store = openDataStore({ kind: "json", path: "./extract.json" });

const proved = await sdk.checkAndProve(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
}, { mode: "offline" });

console.log(proved.decision); // "yes" or "no"
console.log(proved.proof_id); // fresh every call

Where the data can come from

The SDK can query two broad families of external sources, and both feed the same decision and proof flow.

Live networked sources are queried over the network at check time. One group is relational databases, covering PostgreSQL, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, and legacy systems reached through ODBC connections such as PAS and mainframe estates. For these, the host application supplies driver executors that know how to talk to the database, and the SDK uses those executors to run the lookup. The other group is any HTTP service that returns JSON. For these, you configure a profile with a base address, a path template that names where the record lives, optional authentication material taken from an environment variable, and a JSON path that says where the record sits inside the response. The SDK then fetches from that service and extracts the record for checking.

Live source options
SourceHow it is wired
PostgreSQL, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, ODBCHost-supplied driver executor, wrapped read-only with a timeout
Any HTTP JSON APIProfile with base URL, path template, env-var auth, JSON record path
import { AffixSDK, SqlStore, createPgExecutor } from "affixio";
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.AFFIX_SQL_PRIMARY_URL });
const executor = createPgExecutor(pool, { readOnly: true, timeoutMs: 10_000, role: "primary" });

const store = SqlStore.fromExecutor(executor, {
  table: "records",
  dialect: "postgres",
  lookupSql: "SELECT id, status, site FROM records WHERE id = :id LIMIT 1",
  readOnly: true,
  timeoutMs: 10_000,
});

const sdk = new AffixSDK({ apiKey: process.env.AFFIX_API_KEY });
const proved = await sdk.checkAndProve(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
}, { mode: "offline" });
import { createHttpDataStore, testHttpProfile } from "affixio";

const profile = {
  baseUrl: "https://records.example.com",
  pathTemplate: "/v1/records/{id}",
  authEnv: "AFFIX_RECORDS_API_TOKEN",
  recordsPath: "data.record",
  timeoutMs: 10_000,
};

const check = await testHttpProfile(profile);
if (!check.ok) throw new Error("HTTP profile unreachable: " + check.error);

const store = createHttpDataStore(profile, "records-api");

File-based and exported sources are for offline or air-gapped contexts. These are loaded from files rather than reached over a live network call, which makes them suitable where connectivity is restricted, where you work from scheduled exports, or where you want a fixed snapshot to check against. The supported file shapes include plain JSON documents, simple key-value files, comma-separated and tab-separated tables, pipe-delimited and semicolon-delimited tables, fixed-width tables, dBASE and FoxPro database files, LDAP export files in LDIF form, INI-style sectioned files, MongoDB-style document exports, and Redis export dumps. Even though the record comes from a file on disk, it still flows through the same yes or no decision and proof steps as a live lookup.

import { openDataStore } from "affixio";

const json = openDataStore({ kind: "json", path: "./extract.json" });
const csv = openDataStore({ kind: "csv", path: "./extract.csv" });
const tsv = openDataStore({ kind: "tsv", path: "./extract.tsv" });
const pipe = openDataStore({ kind: "pipe", path: "./extract.pipe" });
const scsv = openDataStore({ kind: "semicolon", path: "./extract.scsv" });
const fw = openDataStore({ kind: "fixed_width", path: "./extract.fw", fixedFields: [
  { name: "id", start: 0, length: 10 },
  { name: "status", start: 10, length: 12 },
]});
const dbf = openDataStore({ kind: "dbf", path: "./legacy.dbf" });
const ldif = openDataStore({ kind: "ldif", path: "./export.ldif" });
const ini = openDataStore({ kind: "ini", path: "./wards.ini" });
const mongo = openDataStore({ kind: "mongo", path: "./mongo-export.json" });
const redis = openDataStore({ kind: "redis_export", path: "./redis-export.txt" });
const kv = openDataStore({ kind: "key_value", path: "./lookup.kv" });

How the lookup works at a high level

Every source, whether live or file-based, sits behind a uniform lookup interface. Your service supplies a record identifier, the source returns the matching row or document, or it returns nothing when there is no match. That uniformity is the point. Your service does not need different handling per source type at the point of asking. You ask for a record by identifier, and you get back either a record or a clear absence.

The SDK keeps infrastructure problems separate from absence. Connection failures, timeouts, pool exhaustion, read-only violations, and bad SQL are classified as infrastructure or source errors, distinct from the outcome where the source was reached successfully and simply held no row. This matters because the two cases call for different responses. An infrastructure error can be retried or escalated, while a missing row is a check outcome in its own right.

Lookups are read-only by default, so a check cannot change the source it reads from, and each lookup runs under a configurable timeout so a slow source cannot hold the check open indefinitely. Identifiers are bound into queries safely rather than concatenated into query text, which protects the lookup against injection-style faults regardless of what characters the identifier contains.

import { SqlLookupError } from "affixio";

const outcome = await store.lookupOutcome(
  { id: "REC-1001", claimField: "status", required: "active" },
  { catchInfra: true }
);

if (outcome.kind === "no_row") {
  // Source is healthy; the record is simply absent. Handle as no.
  return { ok: false, reason: "no_row" };
}
if (outcome.kind === "db_error") {
  // Infrastructure fault: timeout, pool, connection, read-only, bad SQL.
  // Retryable or escalatable, unlike no_row.
  throw outcome.error; // SqlLookupError with code + isDbDown
}

const check = outcome.result; // pass <=> fields.claim === fields.required
try {
  await store.lookup({ id: "REC-1001", claimField: "status", required: "active" });
} catch (err) {
  if (err instanceof SqlLookupError) console.error(err.toJSON());
  throw err;
}

From lookup to yes/no decision

Once a record has been returned, the SDK picks out a single claim field from that record. There is a sensible default field it looks for when you do not name one, and there is the ability for the caller to name a specific field when the claim you care about lives elsewhere in the record. The SDK then compares the exact string value of that field to the required value you specified for the check.

The decision is yes only when the two strings are exactly equal. It is deterministic and exact, and it is bound to that specific lookup rather than being a general statement about the person or record. The same claim string can be proved multiple times, and each request stays unique, so repeated checks of the same value do not collapse into one another.

There are three outcome families. In the first, a record was found and the claim matched, which is a yes. In the second, a record was found but the claim did not match, which is a no. In the third, no record was found at all, which is also effectively a no but remains a distinct outcome that your service can handle differently, for example by routing the person down a different journey rather than treating it the same as a mismatch.

// claimField omitted: SDK tries "claim", then "status", then "value".
const fromDefault = await sdk.check(store, { id: "REC-1001", required: "active" });

// Explicit field + extra columns echoed into the proof fields.
const explicit = await sdk.check(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
  select: ["site", "ward"],
});

if (explicit === null) {
  // No record found: distinct from a mismatch.
} else if (explicit.pass) {
  // Record found and fields.claim === fields.required: yes.
} else {
  // Record found, claim did not match: no.
}

From decision to proof

After the decision, the SDK builds a credential-like structure from the check result. As part of that step it creates a fresh credential identifier and a fresh context identifier for each request, so that the unlinkable markers attached to each proof differ every time even when the same claim is proved again. It then produces a proof of the decision.

Two proof modes are available. One is a fast hash-based mode suited to high-throughput yes or no checks, producing an authenticated signed result with efficient verification. The other is a zero-knowledge proof mode built on Noir circuits with UltraHonk proving, for cases where you want zero-knowledge properties on top of the same check. Whichever mode is used, every proof is signed with ML-DSA-65, a post-quantum signature scheme standardised as FIPS 204, at the moment it is created.

Proofs can then take one of two verification routes. They can be verified against the Affix API immediately, which suits online flows, or they can be queued for later batch verification and Merkle audit, which suits offline flows where connectivity is intermittent or where you batch many proofs into a single auditable set. The proof also carries the field values forward as an immutable echo, so that later anyone reviewing the proof can trace the decision back to the exact check that produced it and see precisely what was compared.

// Fast path: HMAC yes/no, ML-DSA-65 signed at creation, offline by default.
const fast = await sdk.checkAndProve(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
}, { mode: "offline", circuitId: "simple_yesno" });

// Zero-knowledge path: same check, UltraHonk over the bundled Noir circuit.
const zk = await sdk.checkAndProve(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
}, { mode: "offline", proofMode: "ultrahonk", circuitId: "yesno" });

// Online path: prove locally, then verify against the Affix API at once.
const verified = await sdk.proveFromCheckAndVerify({
  check: () => store.lookup({ id: "REC-1001", claimField: "status", required: "active" }),
  mode: "online",
  circuitId: "simple_yesno",
});

// Offline batch path: proofs queue locally, then flush to verify + Merkle audit.
const queued = await sdk.checkAndProve(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
}, { mode: "offline", queueForSync: true });
await sdk.flushOfflineQueue();

Two store profiles in the SDK configuration

The SDK configuration distinguishes an internal store profile and an external store profile. Both profiles are backed by the same HTTP data store mechanism, and the distinction is about roles rather than mechanics. Broadly, one profile serves the records your own service holds, while the other serves records reached through outside systems. The same lookup and prove flow works regardless of which profile answers the query, so your service can keep a clear separation between inside and outside data without changing how checks are run or how proofs are produced.

import { AffixSDK, createHttpDataStore } from "affixio";

const sdk = new AffixSDK({
  apiKey: process.env.AFFIX_API_KEY,
  connections: {
    internal: {
      baseUrl: "https://internal.example.com",
      pathTemplate: "/v1/records/{id}",
      recordsPath: "data.record",
      timeoutMs: 10_000,
    },
    external: {
      baseUrl: "https://partner.example.com",
      pathTemplate: "/v1/records/{id}",
      authEnv: "AFFIX_PARTNER_API_TOKEN",
      recordsPath: "data.record",
      timeoutMs: 10_000,
    },
  },
});

const health = await sdk.testConnections(); // licence, internal, external, HSM
const internal = createHttpDataStore(sdk.connections.internal, "internal");
const external = createHttpDataStore(sdk.connections.external, "external");

Why this matters for a DVS or trust-framework-aligned service

In the context of the United Kingdom Digital Verification Service Trust Framework at version 1.0, the SDK can sit inside a certified service as the proving and attestation layer. It lets the service say, in effect, that it looked up a given identifier from a given source, that the result was a particular value, and that here is a signed proof of that lookup and decision. That gives the service a tamper-evident, per-request, post-quantum-signed record of what was checked and what the outcome was.

The SDK does not itself certify the service or the source. Certification of the service belongs to the service provider and their conformity assessment, and the standing of any source belongs to how that source is governed and recognised. For sources that are data brokers or other third-party authoritative sources, the service would still need to handle the trust-framework requirements around those sources, including how the source is treated, how data minimisation is observed, and how the audit trail is kept. The SDK does not take on those duties. Its job is the proof-of-check layer on top of whatever check the service runs, giving the service something it can retain, show, and verify.

// The retained artefact per request: decision, echo of fields, signatures.
const kept = await sdk.checkAndProve(store, {
  id: "REC-1001",
  claimField: "status",
  required: "active",
}, { mode: "offline", queueForSync: true });

const artefact = {
  proof_id: kept.proof_id,
  decision: kept.decision, // "yes" or "no"
  fields: kept.fields, // immutable echo of claim vs required
  proof_digest: kept.proof_digest,
  signature: kept.signature, // ML-DSA-65, signed at creation
};
await sdk.flushOfflineQueue(); // later: verify + Merkle audit batch

Things to think about before wiring it up

Start by deciding which source is authoritative for the claim you care about, since the yes or no is only as trustworthy as the source and the check you configured. The proof attests to the check result, not to the inherent truth of the source itself.

Consider whether you want live network lookups or offline cached and exported lookups. Live lookups reflect the source as it stands at check time, while file-based lookups reflect a snapshot, which can be preferable for air-gapped operation, for resilience, or for pinning a check to a known export.

Plan how your service handles each of the three outcome families in the user journey. A match, a mismatch on a found record, and no record found are different situations, and the people using your service will need different guidance in each case.

Decide whether you want online immediate verification or offline queued verification with Merkle audit, based on your connectivity, latency needs, and how you want to retain evidence.

Work out how authentication for the external database or API is managed. The SDK reads credentials from environment variables and does not store them itself, so ownership of rotation, scoping, and safe handling stays with your service and its hosting environment.

Finally, decide what happens when the external source is unavailable. The lookup distinguishes a source that is down from a record that is simply not there, and the two can be retried or handled differently. Make sure your journey, your monitoring, and your support handling reflect that difference.

// Recommended wiring shape: lookupOutcome first, then prove, then branch.
const flow = await store.lookupOutcome(
  { id: "REC-1001", claimField: "status", required: "active" },
  { catchInfra: true }
);

if (flow.kind === "db_error") {
  // Source unavailable: retry, fail over to replica or snapshot, alert.
  throw flow.error;
}
if (flow.kind === "no_row") {
  // No record: dedicated journey, not the same as a mismatch.
  return { route: "not_found" };
}
const final = await sdk.proveFromCheck({ check: flow.result, mode: "offline" });
return { route: final.decision === "yes" ? "pass" : "mismatch", proof_id: final.proof_id };

Next: main SDK docs for install and proving, developer resources for keys and environments, and copyable SQL recipes in the package at examples/sql/postgres.mjs, examples/sql/mysql.mjs, and examples/sql/odbc.mjs.

UK age verification context: the Online Safety Act, Ofcom, and where this SDK fits

Since the UK Online Safety Act brought enforceable age assurance duties into force, with Ofcom requiring highly effective age checks on services that publish or host pornographic content from July 2025 and broader child-safety duties phasing in around it, many UK teams are rebuilding their age gates. The regulator cares about outcomes: the check must work reliably, must not be trivially circumvented, and must protect children while handling adult privacy with care. How you meet that bar, which assurance method you choose, and how you evidence it are decisions for your service, not for this SDK.

That is exactly where the data-check-then-prove pattern earns its place. Most services already hold the evidence they need: a verified date of birth from onboarding, an age band from an ID check, or an account attribute from a trusted record. The SDK lets you check that attribute where it already lives and keep a signed yes or no with ML-DSA-65 evidence, instead of copying identity documents into another system or building a bespoke attestation layer. The record stays in your environment. Only the decision and its proof travel onward.

Highly effective checks, evidenced per request

Each lookup produces one fresh proof with its own credential and context identifiers, queued for Merkle audit. When Ofcom or an auditor asks what happened on a given attempt, you can show the decision, the echoed fields, and the signature, not just a log line.

Age Verification API

Data minimisation by design

Dates of birth and identity records never leave your host. The SDK proves locally and syncs only the proof, which aligns with the ICO Age Appropriate Design Code expectation that services collect the minimum data needed for the check.

How AffixIO works

See the pattern running

Try the 13+, 16+, and 18+ gate on the live Age Pass demo, then read the trust posture, proof samples, and compliance notes before you scope your build.

Open Age Pass demo
// Age-gate shape: exact threshold comparison, signed per attempt.
const ageGate = await sdk.checkAndProve(store, {
  id: "USER-48210",
  claimField: "age_band",
  required: "over_18",
}, { mode: "offline", circuitId: "simple_yesno" });

if (ageGate.decision !== "yes") {
  // Route to the age-appropriate journey; keep proof_id for audit.
  return { allowed: false, proof_id: ageGate.proof_id };
}
return { allowed: true, proof_id: ageGate.proof_id };

Related reading: Age Verification API, Age Pass demo, Compliance, Trust Centre, Proof Library, Use cases.

UK age verification FAQ

Direct answers for teams scoping Online Safety Act age checks, DVS-aligned verification, and SDK integration.

How does the SDK check age for UK Online Safety Act compliance?

Your service holds the age evidence, for example a date of birth or an age band from an ID check or account record. The SDK looks that record up from your database, API, or file export, compares one field against the threshold you require, such as over eighteen, and returns a signed yes or no. Only the decision and its proof leave your environment; the underlying record never moves to AffixIO. How that decision maps to your Ofcom duties remains your responsibility as the regulated service.

Does AffixIO replace an Ofcom-recognised age verification provider?

No. AffixIO is not an age verification provider and the SDK does not certify your service. Ofcom regulates the services that must carry out highly effective age assurance, and your choice of age assurance method stays with you. The SDK sits inside your service as the proving and attestation layer, giving you a tamper-evident, per-request, post-quantum-signed record of what was checked and what the outcome was.

Which age thresholds can I enforce: 13+, 16+, 18+?

Any threshold your policy needs. The decision is an exact string comparison between the claim field from the record and the required value you specify, so thirteen plus, sixteen plus, eighteen plus, or any policy band all work the same way. See the live pattern on the Age Pass demo and the Age Verification API page.

Do I have to send personal data to AffixIO to verify age?

No. The lookup runs in your environment against sources you already operate, and the proof is generated locally by the SDK. What leaves your host is the yes or no decision with its signed proof and Merkle audit trail, not the date of birth, identity document, or account record behind it. That data minimisation is the core reason the pattern fits age-gated services.

What happens when the age record is missing or the database is down?

The SDK treats those as different outcomes. A reached source with no matching row is a distinct no-row outcome you can route to its own user journey, while connection failures, timeouts, pool exhaustion, and bad queries raise classified infrastructure errors you can retry or escalate. The wiring section shows the recommended lookupOutcome branching shape.

Can this fit inside a DVS trust-framework-aligned service?

Yes, as the proof-of-check layer. Under the UK Digital Verification Service Trust Framework the SDK lets your service show it looked up a given identifier from a given source and produce a signed proof of the lookup and decision. Certification of your service and recognition of your sources still belong to you and your conformity assessment.

HMAC or zero-knowledge proofs for age checks?

High-throughput age gates normally use the fast hash-based mode, which produces an authenticated signed result with efficient verification. Where you need zero-knowledge properties on top of the same check, the SDK can instead prove through Noir circuits with UltraHonk proving. Every proof is signed with ML-DSA-65 at creation either way.

Can I run age checks offline or in an air-gapped estate?

Yes. File-based sources such as JSON, CSV, fixed-width, dBASE, LDIF, INI, MongoDB-style, and Redis exports load from disk with no live network call, and proofs can queue locally for later batch verification and Merkle audit when connectivity returns. Legacy PAS and mainframe estates reachable over ODBC work through the same flow.

Ship your UK age gate

Prove over-18 in your own environment, with evidence to show for it.

Install affixio v1.3.3, point it at the records you already hold, and return a signed yes or no for every 13+, 16+, or 18+ check. Records stay on your host. Proofs carry ML-DSA-65 signatures and queue for Merkle audit, online or offline.

Free to evaluate. Licence key from Hub. No identity records sent to AffixIO.