Example: recording Stripe subscriptions as Txpass receipts
You keep billing with Stripe exactly as today. This example forwards the Stripe events that change what a customer is entitled to to Txpass, which records each paid period on chain as a subscription receipt for the customer's wallet identity. Txpass never sees amounts, cards or invoices, and takes no part in billing: it only records the window you tell it.
You need:
@txpass/clientand@bsv/sdk(npm i @txpass/client @bsv/sdk stripe express).- Your publisher key: an owner identity of your product in
/publisher(the key you sign in with there), as a WIF in your server's secret store. Txpass only knows its public key; there is no Txpass API key. - A way to find the customer's wallet identity key (from your wallet linking flow) and a small table to remember the current receipt per subscription.
- A Stripe price → Txpass plan map. Plans are registered with Txpass by slug and duration only.
The field names below are for Stripe API version 2025-03-31 or later (billing periods on subscription items, invoice.parent.subscription_details, invoice payments).
Event mapping
| Stripe event | Txpass call |
|---|---|
invoice.paid (subscription invoice) | issueReceipt for the paid period. Renewals chain: the new valid_from is the end of the current receipt, so access never lapses. |
customer.subscription.created (status active) | Same issueReceipt as the matching invoice.paid, under the same event_id, so whichever arrives first records it and the other is a no-op. |
customer.subscription.updated, price changed | changePlan: revoke the current receipt, issue the new plan for the rest of the period. |
customer.subscription.updated, cancel_at_period_end set | Nothing. The receipt ends at valid_until. |
customer.subscription.updated, status past_due / unpaid | Your choice. This example leaves the receipt to run out; call revokeReceipt instead to cut access now. |
customer.subscription.deleted before the period ended | revokeReceipt (immediate cancel). |
charge.refunded (full refund) | revokeReceipt. |
| Customer links a wallet in your app | syncReceipt once for the current period. |
Code
import express from 'express'
import Stripe from 'stripe'
import { PrivateKey, ProtoWallet } from '@bsv/sdk'
import { createPublisherClient } from '@txpass/client'
import { db } from './db.js' // your storage: wallet identities and current receipts
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const txpass = createPublisherClient({
facilitatorUrl: 'https://txpass.app',
wallet: new ProtoWallet(PrivateKey.fromWif(process.env.TXPASS_PUBLISHER_KEY_WIF!)),
})
const PRODUCT = 'imprintai-api'
const PLAN_BY_PRICE: Record<string, string> = {
price_monthly_pro: 'pro-month',
price_yearly_pro: 'pro-year',
}
/** The paid period and plan of a subscription (first item). */
function periodOf(sub: Stripe.Subscription) {
const item = sub.items.data[0]!
return {
plan: PLAN_BY_PRICE[item.price.id],
valid_from: item.current_period_start,
valid_until: item.current_period_end,
}
}
/** One event id per subscription period: `invoice.paid` and `subscription.created` share it. */
const periodEventId = (subId: string, start: number) => `${subId}:${start}`
async function recordPeriod(sub: Stripe.Subscription) {
const identity = await db.walletIdentityForCustomer(String(sub.customer))
const p = periodOf(sub)
if (!identity || !p.plan) return // not linked yet (syncReceipt later) or not a Txpass plan
const r = await txpass.issueReceipt({ product: PRODUCT, identity, event_id: periodEventId(sub.id, p.valid_from), ...p, plan: p.plan })
// event_id_conflict: this period is already recorded under another plan (a plan change handled it).
if (!r.ok && r.error === 'event_id_conflict') return
if (!r.ok) throw new Error(`txpass ${r.status} ${r.error}`) // non-2xx → Stripe retries the webhook
await db.setCurrentReceipt(sub.id, r.body.receipt_txid!)
}
async function revokeCurrent(subId: string, eventId: string, reason: string) {
const receipt_txid = await db.currentReceipt(subId)
if (!receipt_txid) return
const r = await txpass.revokeReceipt({ product: PRODUCT, event_id: eventId, receipt_txid, reason })
if (!r.ok && r.status !== 404) throw new Error(`txpass ${r.status} ${r.error}`)
}
const app = express()
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(req.body, req.header('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return res.status(400).end()
}
try {
switch (event.type) {
case 'invoice.paid': {
const inv = event.data.object
const subId = inv.parent?.type === 'subscription_details' ? inv.parent.subscription_details?.subscription : null
if (subId) await recordPeriod(await stripe.subscriptions.retrieve(String(subId)))
break
}
case 'customer.subscription.created': {
if (event.data.object.status === 'active') await recordPeriod(event.data.object)
break
}
case 'customer.subscription.updated': {
const sub = event.data.object
const prev = event.data.previous_attributes as { items?: { data?: Array<{ price?: { id: string } }> } } | undefined
const priceChanged = Boolean(prev?.items?.data?.some((i) => i.price))
if (!priceChanged || sub.status !== 'active') break // cancel_at_period_end, past_due, …: nothing here
const identity = await db.walletIdentityForCustomer(String(sub.customer))
const p = periodOf(sub)
const current = await db.currentReceipt(sub.id)
if (!identity || !p.plan || !current) break
const now = Math.floor(Date.now() / 1000)
const r = await txpass.changePlan({
product: PRODUCT, event_id: event.id, replace: { receipt_txid: current },
plan: p.plan, identity, valid_from: now, valid_until: p.valid_until, reason: 'price change',
})
if (!r.revoke.ok || !r.issue?.ok) throw new Error('txpass plan change failed')
await db.setCurrentReceipt(sub.id, r.issue.body.receipt_txid!)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object
const ended = sub.ended_at ?? Math.floor(Date.now() / 1000)
if (ended < sub.items.data[0]!.current_period_end) await revokeCurrent(sub.id, event.id, 'canceled')
break
}
case 'charge.refunded': {
const charge = event.data.object
if (!charge.refunded || !charge.payment_intent) break // partial refund: your choice
const payments = await stripe.invoicePayments.list({ payment: { type: 'payment_intent', payment_intent: String(charge.payment_intent) } })
const invoiceId = payments.data[0]?.invoice
if (!invoiceId) break
const inv = await stripe.invoices.retrieve(String(invoiceId))
const subId = inv.parent?.type === 'subscription_details' ? inv.parent.subscription_details?.subscription : null
if (subId) await revokeCurrent(String(subId), event.id, 'refund')
break
}
}
res.json({ received: true })
} catch (err) {
console.error('stripe → txpass failed', event.type, event.id, err instanceof Error ? err.message : err)
res.status(500).end() // Stripe retries; Txpass calls are idempotent on event_id
}
})
// When a customer links a wallet in your app: record the period they already paid for.
export async function onWalletLinked(customerId: string, identity: string) {
await db.setWalletIdentity(customerId, identity)
const subs = await stripe.subscriptions.list({ customer: customerId, status: 'active' })
for (const sub of subs.data) {
const p = periodOf(sub)
if (!p.plan) continue
const r = await txpass.syncReceipt({ product: PRODUCT, identity, event_id: `link:${sub.id}:${p.valid_from}`, ...p, plan: p.plan })
if (r.ok && r.body.receipt_txid) await db.setCurrentReceipt(sub.id, r.body.receipt_txid)
}
}
app.listen(3000)
Notes
- Retries are safe. Stripe retries failed webhooks; Txpass treats the same
event_idwith the same content as one request and returns the stored result (idempotent_replay: true). Each call is freshly signed (new nonce and timestamp), so retries never trip replay protection. - Ordering. Stripe does not guarantee event order. A renewal that arrives before the earlier period was recorded is rejected with
400 receipt_window_invalid(nothing to chain after); returning 500 makes Stripe retry it after the earlier event lands. - Trials. This example records only paid periods. To give access during a trial, also call
issueReceiptwhen the subscription istrialing. - Errors worth alerting on:
403 publisher_not_authorized(the key is not registered for the product),400 receipt_unknown_plan(plan not registered with Txpass) and503 issuer_not_configured. See the Facilitator API for every status.