Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@validpay/node-sdk

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@validpay/node-sdk

Official ValidPay Node.js SDK — client-side AES-256-GCM encryption + commitment hashing + split-key + selective disclosure + revocation. Zero production dependencies.

latest
Source
npmnpm
Version
0.4.0
Version published
Maintainers
1
Created
Source

@validpay/node-sdk

Official Node.js SDK for ValidPay — document verification API with client-side AES-256-GCM encryption. Sensitive payloads are encrypted on your server before they ever leave the box; ValidPay stores the ciphertext, and only your verifier (with the key you hand them) can read the contents.

  • Zero production dependencies — Node.js built-in crypto + native fetch only
  • AES-256-GCM authenticated encryption (tampering is detected on decrypt)
  • Hybrid commitment scheme — SHA-256 commitment hash detects server-side tampering
  • Split-key verification (Patent C) — XOR-share the key so neither party alone can decrypt
  • Selective field disclosure (Patent E) — encrypt fields independently, gate per role
  • Blind revocation (Patent H) — revoke / reinstate / inspect audit history
  • Time-locked verification (Patent D) — validFrom / validUntil windows
  • TypeScript-first, ESM-only, requires Node >= 20
  • The encryption key is never sent to the ValidPay API

Install

npm install @validpay/node-sdk

Quick start

import { ValidPayClient } from "@validpay/node-sdk";

const client = new ValidPayClient({ apiKey: process.env.VALIDPAY_API_KEY! });

// 1. Issuer side — register an intent with sensitive payload.
// Split-key protection (Patent C) is the default since 0.4.0: `key` is
// Share A of the AES key; Share B is stored on the ValidPay server. The
// full decryption key never exists on any single system.
const { retrievalId, key } = await client.createIntent({
  documentType: "ssn_card",
  payload: { ssn: "123-45-6789", name: "Jane Doe" },
});

// retrievalId is public (e.g. "vp_abc123def456") — embed in a QR code.
// key (Share A) is secret — deliver it ONLY to the intended verifier, out-of-band.

// 2. Verifier side — fetch and decrypt (no API key needed)
const result = await client.verifyIntent<{ ssn: string; name: string }>(retrievalId, key);

console.log(result.payload);             // { ssn: "123-45-6789", name: "Jane Doe" }
console.log(result.integrityVerified);   // true — commitment hash matched
console.log(result.issuer);              // "Acme Bank"
console.log(result.issuerVerified);      // true

Building a verification URL

The retrievalId is public; the key is secret. Stamp them into a URL fragment (the # part — fragments are never sent to the server, even by curl) so a single link both identifies the intent and decrypts it:

function toBase64Url(b64: string): string {
  return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

const verifyUrl = `https://validpay.com/verify/${retrievalId}#key=${toBase64Url(key)}`;
// → encode in a QR, paste in an email, scan with a phone camera.
// The /verify page reads the fragment client-side and decrypts locally.

toBase64Url matters because phone QR scanners + browser share-sheets mangle +, /, and = in URL fragments. The /verify page accepts both standard base64 and base64url for backward compatibility, but new links should always emit base64url.

How it works

  • createIntent generates a fresh 256-bit key, encrypts your payload locally with AES-256-GCM, computes a SHA-256 commitment hash of the plaintext, and POSTs only the ciphertext + hash to POST /v1/intent.
  • The API returns a public retrieval_id and stores the ciphertext + commitment hash.
  • You hand the verifier the retrievalId and the key through your own secure channel.
  • The verifier calls verifyIntent, which fetches GET /v1/intent/:id, decrypts the ciphertext locally, then recomputes the commitment hash and compares — any server-side tampering would change the hash.

The key is generated client-side, used client-side, and transmitted client-side. ValidPay can never read the payload.

API reference

new ValidPayClient(options)

OptionTypeDefaultNotes
apiKeystring (required)Your ValidPay issuer API key.
baseUrlstring"https://api.validpay.com"Override for staging or self-hosted setups.
timeoutnumber30000Request timeout (ms).
fetchtypeof fetchglobal fetchInject a custom fetch (useful for testing).

Core

client.createIntent({ documentType, payload, validFrom?, validUntil?, splitKey? }) → { retrievalId, key }

Generates a key, encrypts JSON.stringify(payload), posts ciphertext + commitment hash to /v1/intent. Defaults to split-key (Patent C): the returned key is Share A and Share B goes to the server — neither alone decrypts. Pass splitKey: false for the legacy flow where key is the full AES key. The full key is never sent to the API.

client.createIntentBatch(items[]) → { retrievalId, key }[]

Same as createIntent for up to 100 intents in a single request. Each item gets a unique AES key; results match the input order.

client.verifyIntent<T>(retrievalId, key) → VerifyIntentResult<T>

Fetches the intent and decrypts the payload locally. Verifies the commitment hash. Throws ValidPayError:

  • decryption_failed — wrong key or tampered ciphertext (GCM auth-tag failure)
  • integrity_failure — commitment hash mismatch (server-side tampering detected)
  • intent_revoked — the intent has been revoked
  • split_key_required / selective_disclosure_required — use the specialised verify method
interface VerifyIntentResult<T> {
  intentId: string;
  payload: T;
  issuer: string;
  issuerVerified: boolean;
  registeredAt: string; // ISO 8601
  status: string;
  integrityVerified: boolean;
  validFrom?: string | null;
  validUntil?: string | null;
  timeLockStatus?: "valid" | "not_yet_valid" | "expired" | null;
}

Split-key (Patent C) — the default

All documents created with SDK v0.4+ use split-key by default — createIntent returns Share A and stores Share B at the API; verifyIntent detects a split-key intent, fetches Share B from /v1/intent/:id/fragment, XOR-combines, and decrypts:

const { retrievalId, key: shareA } = await client.createIntent({
  documentType: "ssn_card",
  payload: { ssn: "123-45-6789" },
});
// shareA goes in the QR; shareB stays at the API.

const result = await client.verifyIntent(retrievalId, shareA);

Backward compatibility: createIntent({ ..., splitKey: false }) gives the legacy single-key flow; createSplitKeyIntent() is a deprecated alias of createIntent() (emits a DeprecationWarning); verifySplitKeyIntent() still works.

Selective disclosure (Patent E)

const { retrievalId, key } = await client.createSelectiveIntent({
  documentType: "check",
  payload: { amount: 1500, payee: "Alice", memo: "rent" },
  disclosurePolicy: {
    bank: ["amount"],
    auditor: ["amount", "payee"],
  },
});

const bankView = await client.verifySelectiveIntent(retrievalId, key, "bank");
// { amount: 1500, payee: "[REDACTED]", memo: "[REDACTED]" }

const fullView = await client.verifySelectiveIntent(retrievalId, key, "full");
// { amount: 1500, payee: "Alice", memo: "rent" }

Audit + list (Prompt 080)

When you need to reconcile your own records against ValidPay — "how many intents did I create this month, and which got scanned?" — use the audit endpoints. Metadata only; no ciphertext, no key material.

const { intents, total } = await client.listIntents({
  since: "2026-06-01T00:00:00Z",
  status: "active",
  limit: 100,
});
//   total: 142
//   intents[0]: {
//     retrievalId: "vp_abc123def456",
//     documentType: "check",
//     status: "active",
//     createdAt: "2026-06-04T15:52:25Z",
//     verificationCount: 3,
//     lastVerifiedAt: "2026-06-04T16:01:00Z",
//     ...
//   }

const meta = await client.getIntent("vp_abc123def456");
//   status, verificationCount, revokedAt, etc.
//   Use verifyIntent(retrievalId, key) if you want to decrypt.

Filters: since / until (ISO datetime), status (active | revoked), documentType, limit (≤200), offset, order (asc | desc).

Revocation (Patent H)

await client.revokeIntent(retrievalId, "stop payment requested");
await client.reinstateIntent(retrievalId, "false alarm");
const history = await client.getRevocationHistory(retrievalId);

Health

const { status, version } = await client.health();

Low-level crypto helpers

import {
  generateKey,
  encrypt,
  decrypt,
  commitmentHash,
  splitKey,
  combineKeyShares,
  encryptFields,
  buildKeyMap,
  decryptFields,
} from "@validpay/node-sdk";

const key = generateKey();                       // base64 32-byte key
const blob = encrypt("hello world", key);        // base64(iv[12] || authTag[16] || ciphertext)
const plain = decrypt(blob, key);                // "hello world"
const hash = commitmentHash(plain);              // SHA-256 hex

const [a, b] = splitKey(key);
const reconstructed = combineKeyShares(a, b);    // === key

ValidPayError

All SDK errors throw ValidPayError with a stable code:

CodeMeaning
invalid_configMissing apiKey (or other constructor options).
invalid_argumentRequired method argument is missing or invalid.
invalid_keyKey is not valid base64 or not 32 bytes.
invalid_blobBlob is not valid base64 or too short.
decryption_failedWrong key, or ciphertext tampered (GCM auth-tag failure).
integrity_failureCommitment hash didn't match — server tampering detected.
intent_revokedThe intent has been revoked.
split_key_requiredIntent uses split-key; use verifySplitKeyIntent instead.
selective_disclosure_requiredIntent uses per-field encryption; use verifySelectiveIntent.
invalid_roleRole not present in the disclosure policy.
missing_fragmentAPI did not return a key fragment for a split-key intent.
network_errorfetch itself rejected (DNS, TCP, abort, etc.).
http_errorAPI returned non-2xx with no machine-readable error.
not_foundAPI returned 404 (e.g. unknown retrieval ID).
unauthorizedAPI returned 401 (invalid or missing API key).
invalid_responseAPI returned 2xx but response shape was unexpected.
invalid_payloadDecrypted bytes were not valid JSON.

API error codes (wire format)

When the API itself rejects a request, the response body carries a canonical code field alongside the legacy error string. SDKs (this one included) surface both — use code for exhaustive switch checks because the values are stable across versions.

codeHTTPMeaning
INVALID_BODY400Request body failed schema validation. details carries the field-level errors.
INVALID_CREDENTIALS401Wrong email or password on /v1/auth/login.
INVALID_API_KEY401API key is missing, malformed, or revoked.
MISSING_TOKEN401Endpoint requires a bearer token and didn't get one.
INVALID_TOKEN401Bearer token is expired or doesn't decode.
ACCOUNT_LOCKED423Too many failed sign-ins. message carries the retry window.
INSUFFICIENT_SCOPE403API key doesn't have the scope this endpoint requires.
INTENT_NOT_FOUND404No intent matches this retrieval ID.
INTENT_REVOKED200Body is intentionally empty — issuer revoked the intent.
DOCUMENT_LIMIT_REACHED402Free or sandbox quota exhausted. message describes the upgrade path.
PAYLOAD_TOO_LARGE413Encrypted payload exceeds the per-route limit (25 MB for uploads).
RATE_LIMIT_EXCEEDED429Per-API-key bucket exhausted. Honour the Retry-After header.
VALIDATION_ERROR422Domain-level rule rejected the request (e.g. valid_from > valid_until).
NOT_FOUND404Generic — the route exists but the resource doesn't.
INTERNAL_ERROR500Unhandled server error. Retry with backoff; report if it persists.

The full list lives in ValidPay-API/src/errorCodes.ts.

Webhook verification (Prompt 079)

ValidPay POSTs intent events (intent.created, intent.verified, intent.revoked, intent.reinstated) to URLs you register via POST /v1/webhooks. Every delivery carries an HMAC signature in the X-ValidPay-Signature header — verify it before trusting the body:

import express from "express";
import { verifyWebhookSignature } from "@validpay/node-sdk";

const app = express();

app.post(
  "/webhooks/validpay",
  // CRITICAL: read the body as raw bytes, not parsed JSON. The HMAC is
  // computed over the EXACT bytes ValidPay sent; JSON.parse loses the
  // key order and whitespace and the signature won't match.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = (req.body as Buffer).toString("utf8");
    const result = verifyWebhookSignature(
      req.headers["x-validpay-signature"] as string | undefined,
      rawBody,
      process.env.VALIDPAY_WEBHOOK_SECRET!,
    );
    if (!result.valid) return res.status(401).send(result.reason);

    const event = JSON.parse(rawBody);
    switch (event.event) {
      case "intent.revoked":
        // update your local record, remove "Verified" badge, etc.
        break;
      case "intent.verified":
        // someone scanned the QR
        break;
    }
    res.status(200).send("OK");
  },
);

verifyWebhookSignature enforces a 5-minute replay window by default. Configure via the toleranceSeconds option if you need more.

Also worth knowing:

  • X-ValidPay-Delivery-Id carries a per-delivery UUID — deduplicate on this to handle at-least-once retries.
  • Failed deliveries retry on exponential backoff (5s → 30s → 5min). Non-retryable 4xx responses are not re-attempted.
  • The dashboard endpoint GET /v1/webhooks/:id/deliveries returns the last 50 attempts so you can see what landed and what didn't.

Rate limits

All authenticated responses carry three standard headers — read them to pace yourself before you hit a 429:

HeaderMeaning
X-RateLimit-LimitCap per API key per minute. Currently 600.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetUNIX timestamp (seconds) when the window resets.

On 429 you'll also see Retry-After (seconds) — the SDK doesn't auto-retry; honour it from your caller.

Blob format

encrypt() returns a base64 string whose decoded bytes are:

[ iv (12 bytes) | authTag (16 bytes) | ciphertext (variable) ]

This matches the Python SDK exactly, so blobs are interoperable in both directions.

Development

npm install
npm test
npm run build

License

MIT — see LICENSE.

Keywords

validpay

FAQs

Package last updated on 12 Jun 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts