Publisher guide

Sign in with Txpass

Add a Continue with Txpass button to your site. A user signs in on txpass.app with their Txpass ID (a passkey, the Yours wallet or a BRC-100 desktop wallet), confirms that they want to link it to your product, and your backend receives their Txpass identity public key. That key is what agents and subscriptions are tied to, so you can match a signed-in user to their Txpass sessions and receipts.

Your site never sees a passkey, a wallet or a private key. Txpass does not tell you how the user signed in, and gives you no email address, name or payment details.

Early access. The sign-in window is off on txpass.app until Txpass turns it on; until then https://txpass.app/pair answers 404. Talk to us before you ship the button.

How it works

It is the OAuth 2.0 authorization-code flow with PKCE (S256), cut down to one result: the identity key.

  1. Your page makes a random state and a PKCE pair (code_verifier, and code_challenge = BASE64URL(SHA-256(code_verifier))), then opens https://txpass.app/pair in a popup, or redirects to it on phones.
  2. Txpass checks that the return_url's origin is one of your product's return origins. If not, the window shows an error and nothing else happens.
  3. The user signs in and presses Continue as …. Signing in alone never links; the user always confirms.
  4. Txpass makes a one-time code (valid for 60 seconds, usable once, bound to your product, the return origin and the code_challenge) and hands it back:
    • popup: window.opener.postMessage({ type: "txpass:pair", state, code }, returnOrigin), then the window closes;
    • redirect: to return_url?code=…&state=…. A cancel comes back the same way with error: "access_denied".
  5. Your page checks state, sends the code and the verifier to your backend, and your backend calls POST https://txpass.app/v1/pair/token. The answer is { identity_key, product, paired_at }.

A Txpass team member signs in to publishers as their own Txpass ID only, never as a team they act for. A Txpass ID used on a linked device can sign in as its account while that account's plan is live.

1. Register your return origins

In txpass.app/publisher, open your product and find Sign in with Txpass. Enter one origin per line (scheme, host and port only, no path), for example:

https://app.example.com
https://staging.example.com

Rules: https:// only, except http://localhost, http://127.0.0.1 and http://[::1] (any port) for local testing; at most 10 origins; no wildcards. Saving is one wallet signature by a product owner. An empty list turns the button off for your product.

2. Add the button (browser)

With the helper in @txpass/client (the @txpass/client/pair entry has no dependencies and runs in any modern browser):

npm i @txpass/client
import { startPair, completePairRedirect, PairError } from '@txpass/client/pair'

const cfg = { product: 'your-product', returnUrl: location.origin + '/txpass/callback' }

document.querySelector('#txpass')!.addEventListener('click', async () => {
  try {
    const r = await startPair(cfg) // popup on desktop; redirect on phones or if the popup is blocked
    await sendToBackend(r)
  } catch (e) {
    if (e instanceof PairError && e.code === 'access_denied') return // the user cancelled
    throw e
  }
})

// On /txpass/callback (the redirect case). Returns null when the URL has no result.
const back = completePairRedirect()
if (back) await sendToBackend(back)

async function sendToBackend(r: { code: string; codeVerifier?: string; returnUrl: string }) {
  await fetch('/api/txpass/link', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ code: r.code, codeVerifier: r.codeVerifier, returnUrl: r.returnUrl }),
  })
}

Call startPair (or openPairPopup) directly from the click handler, or the browser may block the popup. Do not send Cross-Origin-Opener-Policy: same-origin on the page with the button (use same-origin-allow-popups), or the popup cannot reach your page and sign-in fails (popup_closed). Txpass cannot be shown in an iframe. The helpers create state and the verifier, check state and the sender's origin, and keep the redirect state in sessionStorage for that tab. To keep the verifier on your server instead, create it there and pass state and codeChallenge in; the result then has no codeVerifier.

Optional: identityHint (a 66-hex identity key you already know) makes the window warn when a different Txpass ID is signed in; prompt: 'sign_in' titles the window Sign in to <your product> instead of Continue to <your product> with Txpass.

3. Exchange the code (server)

import { exchangePairCode } from '@txpass/client/pair'

const r = await exchangePairCode({ code, codeVerifier, product: 'your-product', returnUrl })
if (!r.ok) return res.status(400).json({ error: r.error }) // invalid_grant and friends
await linkTxpassId(currentUser, r.identity_key)

Or call the endpoint yourself:

POST /v1/pair/token
Content-Type: application/json

{ "code": "…", "code_verifier": "…", "product": "your-product", "return_url": "https://app.example.com/txpass/callback" }
{ "identity_key": "02ab…(66 hex)", "product": "your-product", "paired_at": 1792300000 }

Every failure (unknown, used or expired code, wrong product, wrong origin, wrong verifier) is 400 { "error": "invalid_grant" }, and the code is used up by the first attempt, right or wrong. Start again from the button.

No API key is needed: the PKCE verifier proves the request comes from the same browser session that started it. Treat the identity key as a public identifier, like a username you did not choose. It is not a credential.

Starting without the helper

Open (popup) or navigate to (redirect):

https://txpass.app/pair?product=your-product
  &return_url=https%3A%2F%2Fapp.example.com%2Ftxpass%2Fcallback
  &state=<22–512 chars of A–Z a–z 0–9 . _ ~ ->
  &code_challenge=<43 chars base64url>
  &code_challenge_method=S256
  &mode=popup            (or redirect)

In popup mode, listen for message events whose origin is https://txpass.app, whose source is your popup, whose data.type is txpass:pair and whose data.state is yours; read data.code or data.error. In redirect mode, read code (or error) and state from the query string of return_url. return_url must not contain a #fragment or credentials, and is at most 2048 characters.

Limits

RouteLimit
POST /v1/pair/check (the window's own lookup)60 / min per IP
POST /v1/pair/codes (signed by the user)20 / min per IP, 10 / min per Txpass ID
POST /v1/pair/token (your backend)60 / min per IP

Over the limit: 429 with Retry-After.

Try it locally

The Txpass repository has a small demo publisher in examples/pair-demo: a page on http://localhost:8790 with the button, and a backend that exchanges the code. Add http://localhost:8790 to your product's return origins, then:

pnpm --filter @txpass/client build
TXPASS_PRODUCT=your-product node examples/pair-demo/server.mjs

API reference: Facilitator API, sign in with Txpass.