@oneaddress/partner-sdk
v1.3.0
Published
Official OneAddress partner integration SDK — webhook verification, address decryption (legacy + D5 session-envelope), LOA validation
Readme
@oneaddress/partner-sdk
Official Node.js SDK for OneAddress partner webhook integrations.
Handles signature verification, replay-window enforcement, ECDH address decryption, and secret-rotation grace windows in a single call.
Installation
npm install @oneaddress/partner-sdkQuick start
import { createOneAddressHandler } from '@oneaddress/partner-sdk';
export const handler = createOneAddressHandler({
webhookSecret: process.env.OA_WEBHOOK_SECRET!,
privateKeyPem: process.env.OA_PRIVATE_KEY_PEM!,
async onUpdate(event) {
// event.address is already decrypted
console.log(`Address updated for ${event.customer.email}:`, event.address);
await db.updateAddress({ email: event.customer.email, ...event.address });
},
});
// Next.js App Router
export { handler as POST };
// Express (requires express.raw middleware on this route)
// app.post('/webhook/oneaddress', express.raw({ type: 'application/json' }), handler);D5 — session-envelope decryption (added in v1.3.0)
OneAddress is rolling out a new dispatch architecture (D5 + D1 + D2 in the design doc) where each address.updated webhook carries:
- A per-partner
session_envelope— AES-256-GCM ciphertext of the new address, account number, IDV reference, verified name, known-names, etc., encrypted under a fresh session key SKn. - A structured
session_key_shareblock — ECDH-ES wrap of SKn under your specific registered key, identified by a stablekey_id. - A per-partner
loa_encrypted— the signed Letter of Authority, encrypted with the SAME scheme + key as the address envelope ({ session_envelope, session_key_share }).
No cleartext customer identity. As of the "no cleartext" mandate the webhook carries no cleartext
customerblock, nocustomer_matchblind-index block, and no cleartextletter_of_authority. The consumer's name, known-names, and your account number are recoverable only by decrypting thesession_envelope; the signed consent only by decryptingloa_encrypted. Nothing about the consumer is knowable before you decrypt. If you usecreateOneAddressHandler,event.customeris populated for you from the decrypted envelope (withemail: ''— match on name / known_names / account_number).
Decrypt via decryptSession:
import { decryptSession } from '@oneaddress/partner-sdk';
const data = await decryptSession(
body.session_key_share,
body.session_envelope,
myPrivateKeyForKeyId, // looked up by share.key_id from your key store
body.partner_id, // your partner UUID, used in HKDF info binding
);
// data.new_address.street, data.account_number, data.verified_name, etc.decryptSession handles both wrap schemes transparently — it routes on share.alg and you just supply the private key matching share.key_id:
ECDH-ES+HKDF-SHA256+A256GCM(default) — your key is ECDH P-256; supply your P-256 PKCS#8 private key.RSA-OAEP-256+A256GCM— your key is RSA (e.g. a KMS/HSM that only exposes RSA); supply your RSA PKCS#8 private key. Register the RSA public key via the Encryption Key → “Use your own RSA key (KMS / HSM)” option in the partner portal. In this scheme there is no ephemeral key (share.epkis absent); the session key SKn is RSA-OAEP-encrypted directly to your RSA public key.MLKEM768X25519+HKDF-SHA256+A256GCM— post-quantum hybrid (X-Wing = ML-KEM-768 + X25519, perdraft-connolly-cfrg-xwing-kem). Protects SKn against “harvest now, decrypt later” — an adversary who records the wire cannot recover it even with a future quantum computer, because that requires breaking both ML-KEM-768 and X25519. Supply your 32-byte X-Wing secret (base64).share.epkcarries the X-Wing KEM ciphertext. Register the X-Wing public key via the Encryption Key → “Post-quantum (ML-KEM-768 + X25519)” option in the partner portal.
In all schemes the session_envelope is identical (AES-256-GCM under SKn) — only the SKn wrap differs.
Decrypting + verifying the Letter of Authority
The signed LOA travels as loa_encrypted (same { session_envelope, session_key_share } shape, wrapped to the same key_id). Decrypt it, verify the signature, and echo the reference back in your confirm callback:
import { decryptLoaEncrypted, verifyD5LOA, d5LoaRef, fetchLoaPublicKey } from '@oneaddress/partner-sdk';
const loa = await decryptLoaEncrypted(body.loa_encrypted, myPrivateKeyForKeyId, body.partner_id);
const loaKey = await fetchLoaPublicKey(); // cache this
if (!(await verifyD5LOA(loa, loaKey))) throw new Error('LOA signature invalid');
// Echo the ref in your /api/confirm POST so OneAddress can confirm you decrypted
// a valid signed consent. It carries no name in the clear.
await fetch(confirmUrl, { method: 'POST', /* …auth… */,
body: JSON.stringify({ dispatch_id, partner_id, status: 'confirmed', loa_ref: d5LoaRef(loa) }) });If you use createOneAddressHandler, this is done for you: pass loaPublicKeyPem and the handler decrypts + verifies + rejects on failure, and exposes event.loa (the decrypted LOA) and event.loa_ref (echo it in your confirm callback).
Post-quantum SDK availability. The X-Wing (
MLKEM768X25519+…) scheme is supported today in the TypeScript (this package) and Go SDKs. Python, Java, and C# X-Wing decapsulation is available on request — X-Wing is a 2024 draft without a mature drop-in library in those languages yet, so we build it per-SDK on demand (verified against the canonicalsession_pqtest vector). Contact OneAddress engineering if you need a Python/Java/C# post-quantum build. ECDH-ES and RSA-OAEP are supported in all five SDKs.
Key versioning + retention obligations
Under D5 your partner_keys registry holds your active key plus zero-or-more retired keys still within their 90-day retention window. Each envelope you receive tells you (via session_key_share.key_id) which retained key OneAddress used to wrap it.
Your contractual obligations:
- Retain the private key for any
key_idwhose paired public key has adestroy_afterdate in the future. You may destroy the private key on or afterdestroy_after— the corresponding session ciphertext on OneAddress's side has been purged by then. - Retain the
session_key_shareobject alongside the update record untilkey_share_expiry. After that date OneAddress will not request reconstruction. - Cooperate in good faith with OneAddress reconstruction requests for in-window envelopes citing a legitimate
reason_code— by decrypting the relevant share and returning SKn over the mutually-authenticated reconstruction channel.
See docs/PARTNER_INTEGRATION.md for the full integration spec.
Legacy ECDH still supported
The pre-D5 decryptAddress helper is unchanged and still works for any webhook carrying the legacy address_encrypted field. A webhook will carry either the legacy shape or the D5 shape — never both. Detect by checking for session_envelope:
if (body.session_envelope) {
const data = await decryptSession(body.session_key_share, body.session_envelope, myKey, body.partner_id);
} else {
const data = await decryptAddress(body.address_encrypted, myKey, body.partner_id);
}The legacy helper is deprecated and will be removed in v1.3.x after all OneAddress dispatches have migrated to D5.
Blind-index matching (zero-plaintext record matching)
Superseded by the "no cleartext" mandate. OneAddress no longer emits a cleartext
customerblock or acustomer_matchblock onaddress.updateddispatches — identity now travels only inside the encryptedsession_envelope. ThecomputeBlindIndex/normalizeForBlindIndexhelpers remain exported for partners who still use blind indexes for their own internal matching, but the dispatch payload no longer carriescustomer_match. Match on the decryptedverified_name/known_names/account_numberinstead. The section below is retained for historical reference.
By default each webhook carries a cleartext customer block (name / email /
account_number / known_names) so you can locate your record before
decrypting the address. If you enable blind-index matching in the partner
portal, OneAddress sends a customer_match block instead — each value is
base64url(HMAC-SHA256(yourBlindIndexSecret, normalize(field))). No plaintext
name/email/account crosses the wire, and you can still match without
decrypting the envelope.
import { computeBlindIndex } from '@oneaddress/partner-sdk';
// One-time: backfill a blind-index column on your records.
// email_bidx = computeBlindIndex(secret, 'email', record.email)
// Then, on each webhook:
async function onUpdate(event) {
const m = event.customer_match; // present when blind-index is enabled
if (m) {
const emailIdx = await computeBlindIndex(process.env.OA_BLIND_INDEX_SECRET!, 'email', '' /* n/a — you query by index */);
const record =
m.email ? await db.findByEmailBidx(m.email)
: m.account_number ? await db.findByAccountBidx(m.account_number)
: await db.findByAnyNameBidx(m.names); // match if ANY name index hits
// ... then decryptSession(...) to get the new address.
} else {
// Cleartext path (default): match on event.customer.
}
}The blind-index secret is generated and shown in the portal (Encryption Key → Blind-index matching). It is distinct from your webhook/confirm/setup secrets.
Normalisation is part of the contract — always compute indexes with the
SDK's computeBlindIndex (or normalizeForBlindIndex), never by hand:
| Field | Normalisation |
| --- | --- |
| email | NFKC · trim · lowercase |
| name | NFKC · trim · lowercase · collapse internal whitespace |
| account_number | NFKC · trim · remove all whitespace · case preserved |
customer_match.names holds one index per name (legal name + each known-name
alias); match if any of your stored name indexes appears in it.
Idempotency — handling retries
If your handler takes longer than ~10 seconds OneAddress will time out and retry the webhook. Your onUpdate can be called more than once for the same address change. Use event.dispatchId (from the X-OneAddress-Dispatch header) as an idempotency key:
async onUpdate(event) {
if (event.dispatchId) {
const seen = await db.dispatches.exists({ dispatchId: event.dispatchId });
if (seen) return; // idempotent no-op
}
await db.updateAddress({ email: event.customer.email, ...event.address });
if (event.dispatchId) {
await db.dispatches.insert({ dispatchId: event.dispatchId });
}
}dispatchId falls back to '' (empty string) if the header is absent — always guard before using it as a key.
At-least-once delivery: The dispatch ID is recorded after
onUpdatesucceeds. This means if your process crashes between a successfulonUpdateand the ID being stored, the same event will be delivered again on retry. This is intentional — the alternative (recording before processing) risks silently dropping updates ifonUpdatefails.Recommendation: Make your database write idempotent (e.g.
INSERT … ON CONFLICT DO UPDATE) rather than relying solely on the dispatchId check. That way a duplicate delivery is harmless even if the ID was never recorded.
Identity verification attestation
Every production dispatch carries a cleartext id_verification block — a server-built attestation that the customer completed identity verification (Global Data DVS + biometric) before the dispatch was authorised. Under the zero-knowledge dispatch rules this outer block carries no customer identity — only the fact and format of the verification:
async onUpdate(event) {
if (event.id_verification) {
// The outer block confirms the update was IDV-authorised, but carries
// no identifying reference. It is safe to log for audit/compliance.
logger.info('IDV-authorised dispatch', {
method: event.id_verification.method, // 'identity_document'
verified_at: event.id_verification.verified_at,
});
}
}| Field | Description |
|---|---|
| method | identity_document when the customer completed a document + biometric check; mock in sandbox builds. Carries no underlying-provider identity. |
| verified_at | ISO-8601 timestamp the verification was completed. |
| verified | Always true when the block is present. |
| platform | Format tag — currently oneaddress/v1. |
The block is absent on BYPASS_STRIPE test flows where no IDV credit was consumed. Production dispatches always include it.
The verification reference lives inside the encrypted envelope
The immutable IDV handle — the verification_ref UUID you quote back to OneAddress support to trace an update to its underlying verification — is not in this cleartext block. Per the zero-knowledge dispatch rules, customer identity (including the IDV reference) stays inside the encrypted session_envelope. After you decrypt it (via decryptSession, or automatically inside createOneAddressHandler), read it from the decrypted SessionData as id_verification_ref:
import { decryptSession, type SessionData } from '@oneaddress/partner-sdk';
const session = await decryptSession(
event.raw.session_key_share,
event.raw.session_envelope,
process.env.OA_PRIVATE_KEY_PEM!,
process.env.OA_PARTNER_ID!,
) as SessionData;
// Persist the ref against your record of the update — it never travels
// in the clear and is only computable after you decrypt the envelope.
await db.updateAddress({
...event.address,
verification_ref: session.id_verification_ref,
});Secret rotation grace window
After rotating your webhook secret in the Partner Portal, OneAddress keeps dispatching webhooks signed with the old secret for up to 24 hours to cover in-flight requests. Pass both secrets to the handler during the transition:
export const handler = createOneAddressHandler({
webhookSecret: process.env.OA_WEBHOOK_SECRET!,
previousWebhookSecret: process.env.OA_PREVIOUS_WEBHOOK_SECRET,
webhookSecretGraceUntil: process.env.OA_WEBHOOK_SECRET_GRACE_UNTIL, // ISO-8601
privateKeyPem: process.env.OA_PRIVATE_KEY_PEM!,
async onUpdate(event) { /* ... */ },
});Once webhookSecretGraceUntil passes, the previous secret is ignored automatically. You can then remove those two env vars.
Standalone verifyWithGrace
If you're not using the handler factory, use verifyWithGrace directly instead of calling verifySignature twice:
import { verifyWithGrace, isFreshTimestamp } from '@oneaddress/partner-sdk';
// Replay check first — before signature verify
if (!isFreshTimestamp(req.headers['x-oneaddress-timestamp'])) {
return res.status(400).json({ error: 'Stale timestamp' });
}
const ok = verifyWithGrace(
rawBody,
req.headers['x-oneaddress-timestamp'],
req.headers['x-oneaddress-signature'],
process.env.OA_WEBHOOK_SECRET!,
process.env.OA_PREVIOUS_WEBHOOK_SECRET,
process.env.OA_WEBHOOK_SECRET_GRACE_UNTIL,
);
if (!ok) return res.status(401).json({ error: 'Invalid signature' });Address verification events
Implement onVerify to respond to consumer address-match checks:
export const handler = createOneAddressHandler({
webhookSecret: process.env.OA_WEBHOOK_SECRET!,
privateKeyPem: process.env.OA_PRIVATE_KEY_PEM!,
async onUpdate(event) { /* ... */ },
async onVerify(event) {
const stored = await db.getAddress({ email: event.customer.email });
const match = stored?.street === event.address.street &&
stored?.postcode === event.address.postcode;
return { match, note: match ? undefined : 'Address does not match our records' };
},
});Account verification (pre-payment, guest flow)
OneAddress runs an optional pre-payment existence check during the guest checkout flow. Implement onAccountVerify to participate — partners that don't implement it are silently skipped, the consumer is allowed to continue, and the regular address.updated event still arrives after payment.
The payload carries no encrypted address — just a cleartext customer block — and the consumer is waiting for your response, so reply synchronously within 10 seconds.
export const handler = createOneAddressHandler({
webhookSecret: process.env.OA_WEBHOOK_SECRET!,
privateKeyPem: process.env.OA_PRIVATE_KEY_PEM!,
async onUpdate(event) { /* ... */ },
async onAccountVerify(event) {
// event.customer = { email, name, known_names, account_number }
const row = await db.findCustomer({
accountNumber: event.customer.account_number,
email: event.customer.email,
});
if (!row) return { status: 'no_account' };
const nameOk = row.full_name.toLowerCase() === event.customer.name.toLowerCase()
|| event.customer.known_names.some(
(alias) => row.full_name.toLowerCase() === alias.toLowerCase(),
);
return nameOk ? { status: 'match' } : { status: 'no_match' };
},
});The three statuses surface to the consumer as:
| Returned | Consumer sees |
|----------|--------------|
| { status: 'match' } | ✓ Account found |
| { status: 'no_match' } | ⚠ Couldn't verify — check your account number (editable, retriable) |
| { status: 'no_account' } | ✗ No account found (editable, retriable) |
Any other 2xx response — including the default behaviour when you don't supply onAccountVerify at all — surfaces as "—" (skipped). The consumer can still pay and dispatch.
Failure mode: no_match and no_account are informational only. The consumer is told and may correct their input, but the wizard does not block them from paying. After payment your usual onUpdate handler still fires — apply the same matching rules you would for an account-flow update.
Edge runtime (Cloudflare Workers, Next.js Edge)
import { createOneAddressHandler } from '@oneaddress/partner-sdk/edge';All exports are available on the /edge path. No Node.js-specific APIs are used.
Standalone utilities
import {
verifySignature, // single-secret HMAC verify
verifyWithGrace, // two-secret grace-window verify
decryptAddress, // ECDH + HKDF + AES-GCM decryption (legacy blob)
decryptSession, // D5 session-envelope decryption
transformAddress, // decrypt legacy blob then re-encrypt to a different public key (e.g. KMS)
transformSession, // decrypt D5 session then re-encrypt to a different public key (e.g. KMS)
isFreshTimestamp, // ±5-minute replay-window check
CURRENT_VERSION, // '2026.1'
SUPPORTED_VERSIONS, // ['2026.1']
} from '@oneaddress/partner-sdk';verifySignature(rawBody, signature, timestamp, secret)
Returns true if the HMAC-SHA256 signature is valid. Signing formula: HMAC-SHA256("${timestamp}.${rawBody}").
isFreshTimestamp(timestamp, toleranceSecs?)
Returns true if timestamp (Unix seconds string) is within toleranceSecs of now. Default tolerance: 300 seconds (±5 minutes). Always check freshness before signature — a stale-timestamp check is cheap; a signature verify is not.
decryptAddress(payload, privateKeyPem, partnerId)
Decrypts the address_encrypted blob using your PKCS#8 ECDH P-256 private key.
transformAddress(payload, privateKeyPem, partnerId, recipientSpkiPem)
Decrypts a OneAddress payload and immediately re-encrypts it to a different ECDH P-256 public key — for example, your internal KMS or HSM. The plaintext address is held in memory only for the duration of the call and is never returned to the caller.
import { transformAddress } from '@oneaddress/partner-sdk';
async onUpdate(event) {
// Re-encrypt to your KMS key — plaintext never leaves this function
const kmsBlob = await transformAddress(
event.raw.address_encrypted as EncryptedPayload,
process.env.OA_PRIVATE_KEY_PEM!,
event.partner_id,
process.env.KMS_PUBLIC_KEY_SPKI_PEM!, // your KMS/HSM SPKI PEM public key
);
await db.storeEncryptedAddress(event.customer.email, kmsBlob);
}Each call generates a fresh ephemeral key pair and random HKDF salt — full forward secrecy, no shared state between calls.
transformSession(share, envelopeB64, privateKeyPem, partnerId, recipientSpkiPem)
The D5 session-envelope counterpart to transformAddress. Decrypts a session_envelope (via decryptSession) and immediately re-encrypts the recovered SessionData to a different ECDH P-256 public key — for example, your internal KMS or HSM. The decrypted session is held in memory only for the duration of the call and is never returned to the caller.
Use this for the current dispatch path (session envelopes); use transformAddress for the legacy single-blob address_encrypted path.
import { transformSession } from '@oneaddress/partner-sdk';
async onUpdate(event) {
const body = event.raw; // the full webhook body
const myPrivateKey = keyStore.get(body.session_key_share.key_id);
// Re-encrypt to your KMS key — the address never leaves this function in plaintext
const kmsBlob = await transformSession(
body.session_key_share,
body.session_envelope,
myPrivateKey,
body.partner_id,
process.env.KMS_PUBLIC_KEY_SPKI_PEM!, // your KMS/HSM SPKI PEM public key
);
await db.storeEncryptedAddress(body.customer.email, kmsBlob);
}Zero plaintext in your app tier. If your KMS/HSM exposes an ECDH P-256 public key, you can register that key as your OneAddress encryption key directly — then the consumer's browser encrypts straight to your KMS and you never need
transformSessionat all.transformSessionis the bridge for when your OneAddress key and your KMS key differ. The HKDFinfostring differs fromtransformAddress(oneaddress:transform-session:<partnerId>) so the two paths never derive the same key.
Protocol versioning
The SDK warns (but does not reject) if it receives a protocol version it doesn't recognise:
[OneAddress SDK] Unrecognised protocol version "2027.1".
Supported: 2026.1. Update @oneaddress/partner-sdk to the latest version.This ensures you keep receiving events while updating your SDK. See SUPPORTED_VERSIONS to check which versions the installed SDK build supports.
Testing your integration
Quick smoke test — send a single test webhook from the Partner Portal (Profile → Test Webhook).
Full conformance suite — run 10 checks against your endpoint using real crypto, no mocks:
# Against a live endpoint
npx @oneaddress/conformance test https://your-endpoint.com/webhook --secret whsec_...
# Against a local server (run from the machine where the server is running)
npx @oneaddress/conformance test http://localhost:3001/webhookOr use the Integration Conformance card in the Partner Portal to run the same checks against your configured webhook URL from within the portal.
