SERVER SDK
Verify & apply offers
@boolbul/sdk/server runs on your backend (Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge). It has zero dependencies and talks to your gateway directly — BoolBul never sees your gateway keys.How trust works
- The widget asks BoolBul's edge for a session; the customer picks an offer.
- The edge signs an
offerJwtwith your signing secret: subscription, offer terms, single-use nonce, 5-minute expiry. - Your server verifies it, checks the subscription belongs to the logged-in user, and only then calls Dodo or Stripe.
A customer can't forge or enlarge an offer: changing any byte breaks the signature, and rescues are only counted after a successful edge claim.
One-call integration
Dodo Payments
// app/api/boolbul/apply/route.ts (Next.js; same idea in Express, Hono, Remix...)
import { redeemOffer } from "@boolbul/sdk/server";
const gateway = { gateway: "dodo_payments", apiKey: process.env.DODO_PAYMENTS_API_KEY!, environment: "live_mode" } as const;
export async function POST(req: Request) {
const user = await requireUser(req); // YOUR auth: never trust ids from the body
const { offerJwt } = await req.json();
await redeemOffer(offerJwt, {
signingSecret: process.env.BOOLBUL_SIGNING_SECRET!, // Dashboard -> Settings
expected: { subscriptionId: user.subscriptionId },
// Optional replay guard: return true if this nonce was already used
seenNonce: async (nonce) => !(await redis.set(`boolbul:${nonce}`, 1, { nx: true, ex: 300 })),
gateway,
});
return Response.json({ ok: true });
}
// app/api/boolbul/cancel/route.ts
import { cancelSubscription } from "@boolbul/sdk/server";
export async function POST(req: Request) {
const user = await requireUser(req);
const { reasonId, comment } = await req.json();
// Cancels at period end and records the exit reason in your gateway's churn analytics
await cancelSubscription(user.subscriptionId, gateway, { reasonId, comment });
return Response.json({ ok: true });
}Stripe (only the gateway line changes)
const gateway = { gateway: "stripe", secretKey: process.env.STRIPE_SECRET_KEY! } as const;What each gateway does
| Action | Dodo Payments | Stripe |
|---|---|---|
| Pause N days | Moves next_billing_date N days later (no charge, access kept, nothing to resume). Opt into Dodo's native status: "paused" with pauseStrategy: "status_paused" and call resumeDodoSubscription later. | pause_collection with behavior: void and resumes_at — auto-resumes, access kept. |
| Discount X% for M months | Creates a single-use, product-restricted code (subscription_cycles: M) and re-applies the same product via Change Plan with do_not_bill. | Creates a single-use coupon (repeating, duration_in_months: M) and adds it to discounts, keeping existing ones. |
| Cancel | cancel_at_next_billing_date + cancellation_feedback + comment (or immediate). | cancel_at_period_end + cancellation_details.feedback + comment (or immediate). |
Dodo has no direct “add discount to subscription” endpoint yet, so the discount path uses Change Plan on the same product. Try it once with a
test_mode subscription before going live.Lower-level helpers
import {
verifyOffer, // (jwt, secret, { expected, seenNonce }) -> VerifiedOffer
applyDodoOffer, applyStripeOffer,
pauseDodoSubscription, resumeDodoSubscription, applyDodoDiscount, cancelDodoSubscription,
pauseStripeSubscription, applyStripeDiscount, cancelStripeSubscription,
OfferVerificationError, GatewayError,
} from "@boolbul/sdk/server";OfferVerificationError.code is one of malformed, bad_signature, expired, invalid_claims, identity_mismatch, replayed. GatewayError carries the gateway's HTTP status and body.
Your own billing webhooks
Keep your existing Dodo / Stripe webhooks as the source of truth for access. Paused and discounted subscriptions emit their normal events (subscription.updated on Dodo, customer.subscription.updated on Stripe), so no BoolBul-specific webhook handling is required.