StrimzStrimz
Recipes

A B2B SaaS subscription, end to end

A full integration for a typical B2B SaaS. Checkout signup, recurring billing, failed charges, cancellation.

This is a complete subscription billing integration for a Next.js app. Your database helpers and email sender are left to you.

The scenario: you're shipping a SaaS product called Acme. You have a Pro plan at $99/month (well, 99 USDC/month). Customers sign up, pay monthly, and you want to know when they're churning before it becomes a problem.

What we're building

Two routes plus a webhook handler:

  • /pricing: shows the plan and a "Subscribe" button.
  • /api/checkout/subscribe: redirects to the plan's checkout link.
  • /api/webhooks/strimz: receives the events that matter.

We'll skip a bunch of standard Next.js boilerplate (Tailwind setup, env config, etc.) and focus on the Strimz parts.

One-time setup

Create the plan once, from a script or the dashboard:

scripts/setup-plan.ts
import { StrimzClient } from '@strimz/sdk'
 
const strimz = new StrimzClient({ apiKey: process.env.STRIMZ_API_KEY! })
 
const plan = await strimz.subscriptionPlans.create({
  name: 'Acme, Pro',
  description: 'Everything, unlimited seats, priority support.',
  amount: '99000000', // 99 USDC
  currency: 'USDC',
  interval: 'monthly',
  intervalCount: 1,
  trialPeriodDays: 14,
  successUrl: `${process.env.APP_URL}/welcome?subscription={SUBSCRIPTION_ID}`,
  cancelUrl: `${process.env.APP_URL}/pricing`,
})
 
console.log('Plan id:', plan.id)

Save the plan id in your env, for example STRIMZ_PRO_PLAN_ID=cmq4wck6a0002je6lfmb5bgjp. You'll use it in the checkout route.

Also create a webhook endpoint, subscribed to the events you care about:

scripts/setup-webhooks.ts
const { signingSecret } = await strimz.webhookEndpoints.create({
  url: 'https://acme.com/api/webhooks/strimz',
  description: 'Production',
  events: [
    'subscription.created',
    'subscription.charged',
    'subscription.charge_failed',
    'subscription.cancelled',
    'subscription.lapsed',
  ],
  mode: 'test', // 'live' once you're ready
})
 
console.log('Signing secret (saving this once):', signingSecret)
// Save it: STRIMZ_WEBHOOK_SECRET=whsec_...

The pricing page

A "Subscribe" button. When clicked, POSTs to the checkout route and redirects to the URL the route returns.

app/pricing/page.tsx
'use client'
 
export default function PricingPage() {
  return (
    <div>
      <h1>Acme Pro, 99 USDC / month</h1>
      <ul>
        <li>14-day free trial</li>
        <li>Everything in our product</li>
        <li>Pay in USDC on Arc</li>
      </ul>
      <form action="/api/checkout/subscribe" method="POST">
        <button type="submit">Subscribe</button>
      </form>
    </div>
  )
}

The checkout route

app/api/checkout/subscribe/route.ts
import { redirect } from 'next/navigation'
 
export async function POST() {
  redirect(`https://checkout.strimz.finance/sub/${process.env.STRIMZ_PRO_PLAN_ID}`)
}

The checkout link is built from the plan id. Strimz hosts the page, which handles wallet connect and the two signatures (the EIP-2612 permit and the SubscriptionIntent). Enrolment moves no money. With the 14-day trial the first charge is due when the trial ends; without a trial the scheduler takes it within about 15 minutes. There is no SDK call for the signup itself, and no way to attach metadata to it today.

The webhook handler

This is where the meaningful logic lives. The events you care about are subscription state transitions.

app/api/webhooks/strimz/route.ts
import { verifyWebhookSignature } from '@strimz/sdk'
import { db } from '@/lib/db'
 
const SECRET = process.env.STRIMZ_WEBHOOK_SECRET!
 
export async function POST(req: Request) {
  const signature = req.headers.get('strimz-signature') ?? ''
  const body = await req.text()
 
  const result = await verifyWebhookSignature(body, signature, SECRET)
  if (!result.valid) {
    return new Response('signature failed', { status: 401 })
  }
  const event = JSON.parse(body)
 
  // Idempotency. If we've seen this event before, no-op.
  const seen = await db.processedWebhook.findUnique({ where: { id: event.id } })
  if (seen) return new Response('ok', { status: 200 })
 
  switch (event.type) {
    case 'subscription.created':
      await onSubscriptionCreated(event.data)
      break
    case 'subscription.charged':
      await onSubscriptionCharged(event.data)
      break
    case 'subscription.charge_failed':
      await onSubscriptionChargeFailed(event.data)
      break
    case 'subscription.cancelled':
      await onSubscriptionCancelled(event.data)
      break
    case 'subscription.lapsed':
      await onSubscriptionLapsed(event.data)
      break
  }
 
  await db.processedWebhook.create({ data: { id: event.id, processedAt: new Date() } })
  return new Response('ok', { status: 200 })
}

The actual handlers. What your business logic does for each:

async function onSubscriptionCreated(sub) {
  // A new subscriber signed up. Create their account in your product.
  await db.user.upsert({
    where: { walletAddress: sub.payerAddress },
    create: {
      walletAddress: sub.payerAddress,
      strimzSubscriptionId: sub.id,
      planTier: 'pro',
      // In trial. They have access; you charge them after the trial ends.
      accessUntil: new Date(sub.trialEndsAt ?? sub.currentPeriodEndAt),
    },
    update: {
      strimzSubscriptionId: sub.id,
      planTier: 'pro',
      accessUntil: new Date(sub.trialEndsAt ?? sub.currentPeriodEndAt),
    },
  })
}
 
async function onSubscriptionCharged({ subscription: sub }) {
  await db.user.update({
    where: { strimzSubscriptionId: sub.id },
    data: { accessUntil: new Date(sub.currentPeriodEndAt) },
  })
}
 
async function onSubscriptionChargeFailed({ subscription: sub, charge }) {
  const user = await db.user.findUnique({ where: { strimzSubscriptionId: sub.id } })
 
  await sendEmail({
    to: user.email,
    template: 'subscription-charge-failed',
    data: {
      amount: '99 USDC',
      reason: charge.outcome,
      topUpUrl: 'https://acme.com/wallet',
    },
  })
}
 
async function onSubscriptionCancelled(sub) {
  await db.user.update({
    where: { strimzSubscriptionId: sub.id },
    data: { cancelledAt: new Date(), accessUntil: new Date(sub.currentPeriodEndAt) },
  })
}
 
async function onSubscriptionLapsed(sub) {
  await db.user.update({
    where: { strimzSubscriptionId: sub.id },
    data: { planTier: 'free', accessUntil: new Date() },
  })
 
  // Trigger your win-back flow.
  await scheduleWinBackEmail(sub)
}

subscription.created, subscription.cancelled and subscription.lapsed carry the subscription itself as event.data. subscription.charged carries { subscription, charge, transaction } and subscription.charge_failed carries { subscription, charge }, so the subscription id is at event.data.subscription.id.

A cancel from your API keeps access until the paid period ends, as in the handler above. Strimz stops charging at once either way.

trialEndsAt is null when the payer gets no trial, for example on a second subscription to the same plan, so the handler falls back to currentPeriodEndAt.

After checkout

Once the enrolment confirms, the checkout sends the customer to the plan's successUrl, with {SUBSCRIPTION_ID} filled in. If they cancel, it sends them to cancelUrl.

To keep customers on your pricing page instead of redirecting, swap the form for <StrimzSubscribeButton planId /> from @strimz/sdk-react. It opens the checkout in a popup and calls onSuccess({ subscriptionId, txHash }) when it's done.

Either way, grant access from the webhook handler, not from the browser. subscription.created usually arrives within a few seconds of the enrolment.

What's not in this recipe

Cancellation flow. See Self-serve cancel.

The customer-facing dashboard showing subscription state. Standard CRUD; nothing Strimz-specific.

Failed-charge recovery UI. The email above is the minimum; for a smoother UX you'd build a "Top up your wallet" page in your product and link the customer to it.

Plans-by-tier (Pro + Team + Enterprise). Create one plan per tier; the rest of the integration is the same.

What's also not in this recipe

What happens if Strimz is down. New signups fail while checkout is unavailable. Renewals that fall due during an outage are charged on the next sweep after it ends. If an outage lasts longer than a whole billing period, the contract charges one period and skips the missed ones rather than charging for each.

On this page