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/pairanswers 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.
- Your page makes a random
stateand a PKCE pair (code_verifier, andcode_challenge = BASE64URL(SHA-256(code_verifier))), then openshttps://txpass.app/pairin a popup, or redirects to it on phones. - 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. - The user signs in and presses Continue as …. Signing in alone never links; the user always confirms.
- 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 witherror: "access_denied".
- popup:
- Your page checks
state, sends the code and the verifier to your backend, and your backend callsPOST 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
| Route | Limit |
|---|---|
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.