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

@oneaddress/partner-sdk

v1.8.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.

Not using Node? You do not need this SDK. The full wire protocol is documented in the OneAddress partner protocol specification, in the Docs tab of the partner portal once you have signed up — headers, signing, replay rules, all three key-unwrap procedures with exact HKDF parameters and byte layouts, both callbacks, and an implementation checklist. It is written so a conforming receiver can be built on any platform with no OneAddress code at all. This SDK is the fast path, not the only path.


Installation

npm install @oneaddress/partner-sdk

Quick start

import { createOneAddressHandler, namePoolMatches } 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. event.customer.email is always ''
    // on this event (OneAddress never learns a consumer's email for a
    // dispatch it isn't sent for) - match on account_number and/or name.
    //
    // BOTH CHECKS BELOW ARE REAL, not defensive noise. `customer` is absent
    // entirely if you have opted into blind-index matching, and
    // `account_number` is null when the consumer did not supply one. Under
    // `strict` TypeScript neither compiles without narrowing.
    const customer = event.customer;
    if (!customer?.account_number) {
      throw new Error('Dispatch carried no account number to match on');
    }
    const providedNames = [customer.name, ...customer.known_names];
    const record = await db.findByAccountNumber(customer.account_number);
    if (!record || !namePoolMatches(providedNames, record.fullName)) {
      throw new Error('No matching customer for this dispatch');
    }
    await db.updateAddress({ id: record.id, ...event.address });
  },
  // Connection-verification test records. OneAddress sends five of these
  // before you go live: real signed, encrypted dispatches carrying FABRICATED
  // identities, which you decrypt and relay back.
  //
  // Deliberately NOT routed through onUpdate - the names and addresses are
  // fictitious, and writing them into real customer records is exactly what a
  // separate event type exists to prevent.
  //
  // Handles both address.test (connection verification) and address.test-dispatch
  // (the admin stress test) - same fabricated, encrypted test record, only the
  // event label differs. Leave it out and both are acknowledged with 200,
  // skipped, and logged as skipped; you will not be able to complete connection
  // verification through the SDK.
  async onTest(event) {
    console.log(`test record (${event.event}) ${event.index}/${event.total}`,
      event.customer.name, event.address);
  },
});

// Next.js App Router
export { handler as POST };

// Express — wrap with toExpressHandler. The handler itself is Fetch-shaped
// (it reads req.text() and returns a Response), which is what lets the same
// code run on App Router, Workers, Deno and Bun. Express gives neither, so it
// needs the adapter — passing `handler` straight in throws
// "req.text is not a function" on your first delivery.
//
//   import { createOneAddressHandler, toExpressHandler } from '@oneaddress/partner-sdk';
//
//   app.post(
//     '/webhook/oneaddress',
//     express.raw({ type: 'application/json' }),   // required, see below
//     toExpressHandler(handler),
//   );
//
// express.raw on THIS route is not optional and not merely a body-parser
// preference: the signature is an HMAC over the raw request bytes, so if
// express.json() has already parsed the body those bytes are gone and no
// re-serialisation reproduces them. toExpressHandler refuses a parsed body
// with a named error rather than letting it become a confusing 401.

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_share block — ECDH-ES wrap of SKn under your specific registered key, identified by a stable key_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 customer block, no customer_match blind-index block, and no cleartext letter_of_authority. The consumer's name, known-names, and your account number are recoverable only by decrypting the session_envelope; the signed consent only by decrypting loa_encrypted. Nothing about the consumer is knowable before you decrypt. If you use createOneAddressHandler, event.customer is populated for you from the decrypted envelope (with email: '' — 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.epk is absent); the session key SKn is RSA-OAEP-encrypted directly to your RSA public key.
  • MLKEM768X25519+HKDF-SHA256+A256GCMpost-quantum hybrid (X-Wing = ML-KEM-768 + X25519, per draft-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.epk carries 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, confirmUpdate,
} 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 confirm callback so OneAddress can confirm you decrypted
// a valid signed consent. It carries no name in the clear.
await confirmUpdate({
  partnerId, secret: process.env.OA_CONFIRM_SECRET!,
  dispatchId: event.dispatchId,
  status: 'confirmed', loaRef: 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 canonical session_pq test 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 retention window (your configured retention period, minimum 14 days; the authoritative horizon for each key is its destroy_after date, not a fixed number of days). Each envelope you receive tells you (via session_key_share.key_id) which retained key OneAddress used to wrap it.

Your contractual obligations:

  1. Retain the private key for any key_id whose paired public key has a destroy_after date in the future. You may destroy the private key on or after destroy_after — the corresponding session ciphertext on OneAddress's side has been purged by then.
  2. Retain the session_key_share object alongside the update record until key_share_expiry. After that date OneAddress will not request reconstruction.
  3. 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 the partner portal documentation at partners.oneaddress.io 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 a future major version once all OneAddress dispatches have migrated to D5 - no removal is scheduled yet.


Blind-index matching (zero-plaintext record matching)

Superseded by the "no cleartext" mandate: no event type carries customer_match any more. OneAddress emits neither a cleartext customer block nor a customer_match block on any dispatch or verification webhook: identity travels only inside the encrypted payload (session_envelope on address.updated; address_encrypted on address.verify; customer_encrypted on account.verify). event.customer_match is always undefined. Match on the decrypted verified_name / known_names / account_number instead; if you use createOneAddressHandler, event.customer is populated for you after decryption. The section below is retained for partners who adopted blind indexes while the feature was live and still use them for their own internal matching columns; nothing in it arrives on the wire.

The computeBlindIndex / normalizeForBlindIndex helpers remain exported. Each index is base64url(HMAC-SHA256(yourBlindIndexSecret, normalize(field))), and if you keep blind-index columns on your own records the normalisation contract below still governs how they must be computed.

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 |

(Historical note: while the wire feature was live, customer_match.names held one index per name (legal name plus each known-name alias) and a record matched if any stored name index appeared in it. No event carries that block today.)


Name matching (added in v1.4.0)

Once you've decrypted the envelope (or resolved a blind-index hit), you still need to confirm the customer's name matches your stored record before accepting an address change. Don't write this comparison yourself. A naive approach - substring or prefix matching on whitespace-split tokens - treats an email address as just another name token, and an email's local part can satisfy that comparison against an unrelated customer's first name. That's a real failure mode we found and fixed in our own systems: it lets a stray email address stored in a name-shaped field act as a false-positive match.

The SDK re-exports the same component-aware matcher OneAddress's own reference receivers run:

import { namePoolMatches } from '@oneaddress/partner-sdk';

const data = await decryptSession(/* ... */);
const providedNames = [data.verified_name, ...(data.known_names ?? [])];

const record = await db.findAccount(/* by account_number, etc. */);
if (!namePoolMatches(providedNames, record.name)) {
  // Names don't line up - do not apply the address change.
}

namePoolMatches(providedNames, storedName) returns true when storedName's surname is one of the surnames implied by providedNames, and every one of storedName's given-name tokens is explained by the pool (an exact token, or a single-letter initial matching a pool token). It rejects email-shaped entries on both sides outright - an email can never satisfy the match, however permissive the rest of the pool is. findMatchingStoredName(providedNames, candidates) does the same over a list of candidate stored names and returns the first match, if any.

Note: namePoolMatches assumes given-name-then-surname ordering (it treats the last token as the surname). If your customer base includes surname-first naming conventions, verify this assumption holds before relying on it.


Confirming back — confirmUpdate

Receiving a dispatch is only half the protocol. Until you call back, the consumer's dashboard shows your business stuck on "dispatched" indefinitely — no matter how correctly you applied the change.

import { confirmUpdate } from '@oneaddress/partner-sdk';

const res = await confirmUpdate({
  partnerId:  process.env.OA_PARTNER_ID!,
  secret:     process.env.OA_CONFIRM_SECRET!,   // see "which secret" below
  dispatchId: event.dispatchId,                // from X-OneAddress-Dispatch
  status:     'confirmed',
  note:       'Address updated in CRM',
});

if (!res.ok) console.error('[oneaddress] confirm failed', res.status, res.error);

The three statuses mean different things

| Status | Meaning | | --- | --- | | confirmed | You applied the change. | | already_current | You already held that address; nothing to write. Not a failure — return it honestly rather than reporting confirmed. | | failed | You could not apply it. Send a note saying why. |

Which secret

If you have minted a confirm secret in the Partner Portal, use that one — once it exists it is the only secret /api/confirm accepts, so that a leaked webhook secret cannot forge confirmations. If you have not, use your webhook signing secret. The same secret authenticates the request and signs the body.

Why this helper exists

The endpoint requires a Bearer token, a fresh unix-seconds timestamp, and an HMAC-SHA256 over `${timestamp}.${rawBody}`. Hand-rolling that has three failure modes which all surface as an indistinguishable 401:

  • Signing a string and then sending a re-serialised object. A byte of whitespace or a different key order breaks the signature. confirmUpdate serialises once and sends exactly what it signed.
  • Milliseconds instead of seconds. That is outside the ±300s replay window by a factor of a thousand, and the error reads "stale or future request", which sounds like an auth problem.
  • Clock drift on your host. Same error, same confusion. If confirms fail and nothing else has changed, check your clock.

confirmUpdate never throws on an HTTP status — it returns { ok, status, error }. Log that result. If a confirm fails and nothing inspects it, the update looks applied on your side and the consumer never sees it land: the same silent-success shape as an unhandled event, in the other direction.

It also refuses a missing or non-numeric dispatchId before sending, because /api/confirm treats an absent dispatch_id as a no-op for the dispatch record — so posting one would return 200 having closed no loop at all.


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({ accountNumber: event.customer?.account_number, ...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 onUpdate succeeds. This means if your process crashes between a successful onUpdate and 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 if onUpdate fails.

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.

Let the handler do it — createSqlIdempotencyStore

Rather than hand-rolling the above, pass an idempotencyStore and the handler manages deduplication for you. The SDK ships a SQL-backed implementation that works over whatever driver you already use — it takes a query function, so the SDK stays dependency-free and you reuse your existing pool and credentials:

CREATE TABLE oneaddress_dispatches (
  dispatch_id  TEXT PRIMARY KEY,
  received_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  completed_at TIMESTAMP
);
import { createOneAddressHandler, createSqlIdempotencyStore } from '@oneaddress/partner-sdk';

export const handler = createOneAddressHandler({
  webhookSecret: process.env.OA_WEBHOOK_SECRET!,
  privateKeyPem: process.env.OA_PRIVATE_KEY_PEM!,

  idempotencyStore: createSqlIdempotencyStore({
    // Postgres (node-postgres). For MySQL/SQLite/SQL Server add `placeholder: () => '?'`.
    query: (sql, params) => pool.query(sql, params).then((r) => r.rows),
  }),

  async onUpdate(event) {
    await db.updateAddress({ accountNumber: event.customer?.account_number, ...event.address });
  },
});

Why a claim, not a has/set pair. The older two-method interface still works, but it cannot be made correct under concurrency: has and set are separate round-trips, so two retries arriving at once both observe "not seen" and both apply the update. claim is a single atomic INSERT … ON CONFLICT DO NOTHING and has no such window.

It also distinguishes three states rather than two, and the third is the one that matters:

| Claim | Meaning | Handler does | |---|---|---| | fresh | Never seen | Process it | | retry-after-incomplete | Seen, but the previous attempt never reported completion | Process it again | | duplicate | Seen and completed | Skip, respond 200 { duplicate: true } |

A store that only knows "seen / not seen" has to guess at the middle row. Guess "duplicate" and you lose a paid address update permanently the first time a process dies mid-write — with your side and ours both reporting success.

Do not use an in-memory Set beyond local development. It is per-process (so it does nothing behind a load balancer), unbounded (so it leaks), and lost on restart (so it re-opens the duplicate window on every deploy).


Draining the outbox — createOutboxConsumer

If you run the drop-in receiver, every delivered update lands in a table and is stamped written_at. The receiver never stamps processed_at — that column is yours, and the gap between the two is what makes the backlog metric mean anything.

createOutboxConsumer crosses that gap for you, correctly, on all four databases the receiver supports.

import { createOutboxConsumer } from '@oneaddress/partner-sdk';

const outbox = createOutboxConsumer({
  query: (sql, params) => pool.query(sql, params).then((r) => r.rows),
  dialect: 'postgres',
});

await outbox.ensureSchema();          // once, on boot — adds its lease columns

setInterval(async () => {
  await outbox.process(async (row) => {
    await crm.applyAddress(row.fields);   // your work
  });                                     // marked processed, or failed and retried
}, 5_000);

Why not just SELECT … WHERE processed_at IS NULL

Because that is correct with one consumer and quietly wrong with two. Both read the same rows, both apply the same address, and whichever finishes second overwrites the first. On a CRM that fires side effects on write — a confirmation email, an audit entry, a downstream sync — the duplicate is visible to your customer.

Claiming atomically is dialect-specific, and that is the part this saves you:

| Database | How a batch is claimed | |------------|------------------------| | PostgreSQL | FOR UPDATE SKIP LOCKED in a subquery, RETURNING * — one statement | | SQL Server | UPDATE TOP (n) … WITH (UPDLOCK, READPAST, ROWLOCK) … OUTPUT INSERTED.* | | MySQL | UPDATE … ORDER BY … LIMIT, then read back by claim token — MySQL cannot reference the target table in a subquery (error 1093) and has no RETURNING | | SQLite | Subquery without a lock hint — one writer, so there is nothing to skip |

A claim is a lease, not a lock

A consumer that dies mid-batch must not strand its rows. A claim expires after leaseSeconds (default 300) and the row becomes claimable again.

Your handler must therefore be idempotent. If a lease expires while the handler is still running, the row can be claimed twice — and no timeout is long enough to make that impossible. Set leaseSeconds comfortably longer than your slowest handler, and make the write itself safe to repeat.

Failure, and why nothing is given up on by default

A handler that throws does not stop the batch: the row records its reason in processing_error, counts an attempt, releases its claim and is retried on the next pass. The rest of the claimed batch still runs.

maxAttempts defaults to 0, meaning never stop trying, and that default is deliberate. Giving up on a row is an address that never reaches your system, which is worse than a loop you can see. Set a cap only once you are alerting on backlog().stuck.

The numbers to watch

const { pending, oldestPendingAgeSeconds, stuck } = await outbox.backlog();

oldestPendingAgeSeconds is the one to alert on. A rising value means the receiver is healthy and your consuming job is not — a state that is otherwise silent on both sides until someone asks why an address never appeared.

Columns it adds

ensureSchema() adds oa_claimed_at, oa_claim_token and oa_attempts if they are absent, and is safe to call on every boot. They carry the oa_ prefix because this consumer is also useful over a table you wrote yourself, where a bare claimed_at might already mean something.

Options

| Option | Default | | |---|---|---| | query | — | Runs parameterised SQL, returns rows. Your pool, your driver. | | dialect | postgres | postgres | mysql | sqlite | mssql | | table | oneaddress_updates | What the receiver writes to | | batchSize | 50 | Rows per claim | | leaseSeconds | 300 | How long a claim is held | | maxAttempts | 0 | 0 = never give up |


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

Rotate with an overlap. Staging a new secret in the Partner Portal does not change what we sign with: we keep using your current secret until you press Switch over. So the safe sequence is stage, deploy the new secret alongside the current one, verify with both, then switch. At every point the secret we sign with is one your receiver already holds.

Pass both to the handler for the whole overlap, and for 24 hours after switching — the old secret stays valid for the callbacks you send us during that window:

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

address.verify asks whether a consumer's new address already matches what you hold on file. On a match, OneAddress marks the service "already in sync" and skips the address.updated dispatch — there is nothing to update. On a mismatch, the normal address.updated event follows after the check.

The address travels encrypted as address_encrypted (per-partner ECDH, no cleartext); createOneAddressHandler decrypts it into event.address for you. Return { match } and the SDK reports the result back to OneAddress for you — use the compareAddresses helper for a strict baseline comparison:

import { createOneAddressHandler, compareAddresses } from '@oneaddress/partner-sdk';

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.getStoredAddress(event); // look up by your account reference
    if (!stored) return { match: false, note: 'No address on file' };
    return { match: compareAddresses(stored, event.address) };
  },
});

How the loop closes. Unlike account.verify (which OneAddress reads from your synchronous HTTP response), address.verify results are ingested from a callback the SDK POSTs to the webhook's callback_url (/api/verify-result), carrying { batch_id, partner_id, result, address_match, token }. createOneAddressHandler sends this automatically whenever you provide onVerify. Simply returning from the handler without this callback would leave the verify batch to time out.

If you handle address.verify without the SDK handler, call sendVerifyResult yourself after verifying the HMAC signature and decrypting the address:

import { sendVerifyResult, compareAddresses, decryptAddress } from '@oneaddress/partner-sdk';

const address = await decryptAddress(body.address_encrypted, privateKeyPem, partnerId);
const match   = compareAddresses(await db.getStoredAddress(body), address);

await sendVerifyResult({
  callbackUrl:   body.callback_url,
  callbackToken: body.callback_token,
  batchId:       body.batch_id,
  partnerId,
  match,
});

sendVerifyResult maps match: true → result 'match' + address_match true (OneAddress skips the dispatch) and match: false → 'mismatch' + address_match false (the update proceeds). It throws if the callback POST returns a non-2xx status.


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 address — only the customer identity block (name / known-names / account number), and it travels encrypted as customer_encrypted (per-partner ECDH, same scheme as the address payload), never in cleartext. createOneAddressHandler decrypts it for you and populates event.customer. 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 }, and is
    // OPTIONAL - absent under blind-index matching. Narrow once, then use it.
    const customer = event.customer;
    if (!customer) return { status: 'no_account' };

    const row = await db.findCustomer({
      accountNumber: customer.account_number,
      email:         customer.email,
    });

    if (!row) return { status: 'no_account' };

    // Use namePoolMatches, not a naive string comparison - see "Name matching" above.
    const providedNames = [customer.name, ...customer.known_names];
    const nameOk = namePoolMatches(providedNames, row.full_name);

    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)
  readAddressParts,    // tolerant address field reader (street/line1/address_line1/…)
  formatAddressParts,  // one-line summary of the parts above, for logs + notes
  isFreshTimestamp,    // ±5-minute replay-window check
  CURRENT_VERSION,     // '2026.1'
  SUPPORTED_VERSIONS,  // ['2026.1']
} from '@oneaddress/partner-sdk';

readAddressParts(raw) / formatAddressParts(parts)

Pulls line1 / suburb / state / postcode / country out of a decrypted address regardless of which field-name spelling it carries — street, address_line1, streetAddress; city, locality, town; region, province; postalCode, postal_code, zip. Missing parts come back as '' (country defaults to 'AU'), so you can || straight into a fallback:

const parts = readAddressParts(data.new_address);
await db.updateAddress({
  id:       row.id,
  line1:    parts.line1    || row.line1,
  suburb:   parts.suburb   || row.suburb,
  postcode: parts.postcode || row.postcode,
});
console.log(formatAddressParts(parts)); // "42 Collins Street, Melbourne, VIC, 3000"

This is a tolerant reader, not a schema declaration: it describes what the helper accepts and says nothing about what OneAddress guarantees to send. Use it instead of hand-rolling the ?? ?? ?? ladder — we did that in three receivers and a fix to one silently left the others behind.

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?.account_number, 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
  );
  // Use event.customer (decrypted by the handler), not body.customer - under
  // D5 the raw webhook body carries no cleartext customer block at all.
  await db.storeEncryptedAddress(event.customer?.account_number, 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 transformSession at all. transformSession is the bridge for when your OneAddress key and your KMS key differ. The HKDF info string differs from transformAddress (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/webhook

Or use the Integration Conformance card in the Partner Portal to run the same checks against your configured webhook URL from within the portal.