@reclaimprotocol/client
Typed SDK for the Reclaim Builder API. One package gives you:
createReclaim(...)— a small, declarative client for the consumer happy path: create verification sessions, register callbacks, and receive + verify results.ReclaimClient— a low-level typed HTTP client (call('OperationId', args)) for any operation in the spec.- Crypto helpers — ECIES decryption with the org's eth key, result-JWS signing/verification, JWKS discovery, and nested attestor-proof verification.
Works in Node, Deno, browsers, and edge runtimes (it uses WebCrypto + @noble, no native deps).
The model in one paragraph
Authentication is a single per-org secret (rorg_…, the orgSecret) — a bearer credential; no signatures, no per-call secrets. The org is identified by an Ethereum (secp256k1) public key, set as its keypair. When that keypair's canEncryptResult is enabled, verification results are encrypted with ECIES (secp256k1 + AES-256-GCM) to the org's eth public key, and you keep the matching eth private key (0x-hex — the form any wallet/ethers emits) to decrypt. Ethereum also signs the result JWS with ES256K, which you verify against the Builder's JWKS.
┌─ one-time setup ─────────────────────────┐
reclaim.callbacks.register({ callbackUrl }) ← where results go
reclaim.keypair.set(orgId, { publicKey, ← identity + (optional)
canEncryptResult: true }) encrypt to your key
└──────────────────────────────────────────┘
┌─ per verification ───────────────────────┐
reclaim.sessions.create(...) → open session.verificationUrl
… end-user completes it …
POST → your callbackUrl → reclaim.results.receive(body) → verified payload
└──────────────────────────────────────────┘
Install
npm install @reclaimprotocol/clientQuick start (consumer)
Configure once with your org secret. orgId below is your organization's id (the org the secret belongs to).
import { createReclaim } from '@reclaimprotocol/client'
const reclaim = createReclaim({
orgSecret: process.env.RECLAIM_ORG_SECRET, // rorg_… (issued by an org owner)
})
const orgId = process.env.RECLAIM_ORG_ID // your org's id1. Register a callback (one-time)
Tell the Builder where to POST verification results. This is one-time setup and can also be done from the dashboard or the agent — see Three ways to set up.
await reclaim.callbacks.register({ callbackUrl: 'https://my.app/reclaim/callback' })2. Enable encryption (optional, one-time)
By default results are delivered as plaintext signed JWS (still verified). To have the Builder encrypt results so only you can read them, register your org's eth public key with canEncryptResult: true. Use any eth key (the 0x-hex form wallets/ethers emit), or mint one with the SDK:
import { generateEthKeypair } from '@reclaimprotocol/client'
// keep privateKeyHex secret — it decrypts results
const { privateKeyHex, publicKeyHex } = generateEthKeypair()// (also doable from the dashboard, which can mint the keypair in-browser)
await reclaim.keypair.set(orgId, {
publicKey: publicKeyHex, // 0x04+128 hex (or pass publicKeyJwk)
canEncryptResult: true,
})3. Create a session (per verification)
const session = await reclaim.sessions.create({
providerId: 'prov_…',
context: { userId: 'abc' },
})
console.log('open this:', session.verificationUrl) // hand to the end-userIt's a plain authenticated POST, so you don't need the SDK — curl works too (the org secret is the only auth):
curl -X POST "$BUILDER_URL/verifications/sessions" \
-H "Authorization: Bearer $RECLAIM_ORG_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"providerId": "prov_…",
"context": { "userId": "abc" }
}'
# → 201 { "id": "…", "verificationUrl": "…", "status": "pending", "mode": "…", … }4. Receive & verify the result
When the end-user finishes, the Builder POSTs to your callbackUrl. Pass the request body to results.receive. It decrypts (if encrypted) and verifies the signature + nested proofs, and throws if the result is invalid — so reaching the result branch means it's verified.
// in your callback HTTP handler:
const outcome = await reclaim.results.receive(requestBody, {
credential: process.env.RECLAIM_ETH_PRIVATE_KEY, // 0x-hex eth key; omit for plaintext
expectedReclaimSessionId: session.id,
})
if (outcome.kind === 'result') {
console.log('verified!', outcome.result.payload)
} else {
console.log('progress event:', outcome.event) // non-terminal notification
}Three ways to set up
The one-time setup (registering the callback subscription + the encryption keypair) can be done however suits you — they all hit the same API:
- This SDK —
reclaim.callbacks.register(...)+reclaim.keypair.set(...), as above (good for scripting/CI). - The dashboard — the org's web console has a callback-subscription form and a "Set encryption keypair" dialog that can generate the eth keypair in your browser and download the private key for you.
- The agent (
@reclaimprotocol/agent— thereclaimCLI / MCP server) — handy while building and testing a provider locally.
Whichever you use, at runtime your backend needs only the org secret (to authenticate) and the eth private key (to decrypt) — the results.receive(...) step is unchanged.
API reference
createReclaim(config)
Returns a Reclaim with four namespaces (sessions, callbacks, keypair, results) plus an api escape hatch.
ReclaimConfig field |
type | required | notes |
|---|---|---|---|
orgSecret |
string |
yes | The org secret rorg_…. Sent as Authorization: Bearer; identifies the org server-side. |
baseUrl |
string |
no | Defaults to the production Builder. |
fetch |
typeof fetch |
no | Custom fetch (tests, edge runtimes). |
headers |
Record<string,string> |
no | Extra headers merged into every request. |
orgSecret is the only config. Everything else is passed where it's used: the orgId to keypair.set/get, and the eth private key (0x-hex) to results.receive/decrypt.
reclaim.sessions
reclaim.sessions.create({ providerId, version?, context, verificationClientUrl? })
// → VerificationSession (has `id`, `verificationUrl`, `status`, `mode`, …)
// verificationClientUrl defaults to the Builder's own /v/{sessionId} page.
reclaim.sessions.get(sessionId) // → VerificationSession
reclaim.sessions.listEvents(sessionId) // → VerificationEventRecord[]reclaim.callbacks
reclaim.callbacks.register({ callbackUrl, events? }) // events defaults to the 3 terminal events
// → CallbackSubscription. Org-scoped; no address/publicKey/secret.
reclaim.callbacks.list() // → CallbackSubscription[]
reclaim.callbacks.remove(subscriptionId) // → voidevents is any subset of VerificationEvent. The terminal events (verification_success / verification_rejected / verification_error, exported as TERMINAL_EVENTS) deliver the signed result; non-terminal events deliver a plaintext progress notification.
reclaim.keypair (the org's single eth encryption key)
await reclaim.keypair.set(orgId, { publicKey, canEncryptResult?: true, label?: 'prod' }) // → OrgKeypair
await reclaim.keypair.get(orgId) // → OrgKeypair | undefined (undefined ⇒ keyless / plaintext delivery)publicKey is the org's eth uncompressed public key (0x04 + 128 hex — the form generateEthKeypair()/ethers produce); alternatively pass publicKeyJwk (a secp256k1 JWK). set stores the JWK + derived eth address. There is exactly one keypair per org; set replaces it (rotation). canEncryptResult toggles whether results are encrypted to it (default false ⇒ plaintext). Keep the matching eth private key (0x-hex) and pass it as the decryption key to results.receive/decrypt.
reclaim.results
reclaim.results.receive(body, opts?) // → ReceivedDelivery ← the 90% path
reclaim.results.decrypt(body, credential?) // → Jws (ECIES or plaintext → signed JWS)
reclaim.results.verify(jws, opts?) // → VerifyResultFullOutcomereceive branches on the delivery:
type ReceivedDelivery =
| { kind: 'result'; result: VerifyResultFullOutcome } // terminal: decrypted + verified
| { kind: 'event'; event: VerificationEvent; eventData: Record<string, unknown> } // non-terminal: plaintextopts (ReceiveOptions) is VerifyResultFullOptions (expectedReclaimSessionId nonce check, expectedAud, attestor allowlists, fetchImpl, now) plus an optional credential — the eth decryption key as a 0x-hex private key string (the key content, not a path or env name). All three methods are async; pass credential only when the delivery is encrypted.
Invalid results throw. receive and verify (and the underlying verifyResult / verifyResultFull) never return a failed verdict — they throw a ResultVerificationError carrying the failure reason and the full outcome (payload, reclaimSessionId, per-proof detail). So the result branch is always verified; wrap the call to inspect failures:
import { ResultVerificationError } from '@reclaimprotocol/client'
try {
const { result } = await reclaim.results.receive(body, { credential })
// result.payload is verified
} catch (err) {
if (err instanceof ResultVerificationError) {
console.error(err.reason, err.outcome) // e.g. 'expired' / 'bad-signature' / 'invalid-proof'
} else {
throw err
}
}A non-terminal event delivery is not a result and never throws — it returns { kind: 'event', … }.
reclaim.api — escape hatch
// fully typed against the OpenAPI spec — for any operation the namespaces don't wrap
const { data } = await reclaim.api('GetVerificationSession', { params: { sessionId } })Crypto & key helpers
// Eth encryption keys (consumer side)
generateEthKeypair(): { privateKeyHex, publicKeyHex, publicJwk, ethAddress } // mint an org keypair
loadDecryptionKey(hex): DecryptionKey // 0x-hex eth private key → raw scalar
uncompressedPubkeyToJwk(hex): Jwk // 0x04+128 hex → secp256k1 JWK
pubkeyJwkToEthAddress(jwk): string // secp256k1 JWK → 0x eth address
// ECIES (encryptForEthPublicKey is used by the first-party Verification Client; consumers only decrypt)
encryptForEthPublicKey(publicJwk, bytes): Promise<string> // → ECIES ciphertext (base64url)
decryptCallback(decryptionKey, body): Promise<Jws> // ECIES/plaintext → JWS
// Result JWS (eth ES256K)
signResultJws(ethPrivHex, payload, issuer): Jws // VC-side
verifyResult(jws, opts): VerifyResultOutcome // offline signature/nonce check (throws if invalid)
verifyResultFull(jws, opts): Promise<VerifyResultFullOutcome> // + JWKS discovery + attestor proofs (throws if invalid)
verifyJwsSignature(jws, signerPublicKey), decodeJwsPayload(jws)
// JWKS (signature-key discovery / publishing)
fetchJwks(url), jwksFromPrivateKey(ethPrivHex), selectKey(...), jwkToUncompressedPublicKey(jwk)
// Nested attestor-proof verification
verifyNestedProof(...): NestedProofOutcomeEth keypair utils (low-level)
generateKeypair(), deriveFromPrivateKey(hex), addressFromPublicKey(...), plus the secp instance and addressFromUncompressedPubkey(...), are exported for the Verification Client and the agent (proof-owner signing). A consumer that enables encryption holds an eth private key (the same key family) to decrypt — see the encryption-key helpers above. canonicalJson (RFC 8785) is also exported (used by content-hash binding).
ReclaimClient (low-level)
import { ReclaimClient } from '@reclaimprotocol/client'
const client = new ReclaimClient({ baseUrl, token, fetch, headers })
const { data, response } = await client.call('GetVerificationSession', { params: { sessionId } })Errors are thrown as ProblemError (RFC 9457). throwProblemError, DEFAULT_BASE_URL, USER_AGENT, encoding helpers (b64url, hexToBytes, …), and the generated components / operations types are also exported.
Security notes
- The org secret is a bearer credential — treat it like a password; rotate it from the dashboard (
POST /orgs/{orgId}/token, which invalidates the prior one). The server stores only an HMAC of it. - Encryption is ECIES over secp256k1 + AES-256-GCM (via
eciesjs/@noble) — runs in browsers, Node, Deno, and edge runtimes, and uses the same eth key family as signing. - Signature verification stays secp256k1 (not routed through
jose) so it works on WebCrypto-only runtimes that lackES256K. - The callback delivery is unauthenticated transport — never trust the raw POST body; trust only the decrypted, signature-verified result that
results.receivereturns.