Publisher guide

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/client and @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 eventTxpass 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 changedchangePlan: revoke the current receipt, issue the new plan for the rest of the period.
customer.subscription.updated, cancel_at_period_end setNothing. The receipt ends at valid_until.
customer.subscription.updated, status past_due / unpaidYour choice. This example leaves the receipt to run out; call revokeReceipt instead to cut access now.
customer.subscription.deleted before the period endedrevokeReceipt (immediate cancel).
charge.refunded (full refund)revokeReceipt.
Customer links a wallet in your appsyncReceipt 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_id with 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 issueReceipt when the subscription is trialing.
  • 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) and 503 issuer_not_configured. See the Facilitator API for every status.