When building a SaaS product with paid plans, payment handling is never just about charging a card. You need subscriptions, renewals, upgrades, proration, invoices, webhooks, and failure handling — all working reliably.
In this post, I’ll walk through how to integrate Tap Payments into a Node.js + Express + Sequelize backend for a band-based subscription model, using a real production-style architecture.
This guide focuses on backend design, security, and reliability, not just “making a payment work”.
🧱 Architecture Overview
Our system is built around these core components:
- Express (TypeScript) — API layer
- Sequelize — relational data modeling
- Tap Payments API — card payments
- Webhook-based state sync — payment truth source
- Band-based licensing — users pay per seat band
High-level flow:
Client → Backend → Tap Checkout
↘ Webhook → Backend → DB
## 📦 Subscription & Payment Data Model
### Subscription (Account-level)
Each account has **exactly one subscription**:
```
ts
Subscription
- accountId
- bandSize
- subscriptionType (trial | paid | suspended)
- startDate / endDate
- unitPrice
- totalAmount
- status
SubscriptionPayment (Immutable Ledger)
Every charge creates a payment record:
ts
SubscriptionPayment
- subscriptionId
- tapChargeId
- amount
- currency
- paymentMethod (card | invoice)
- status (pending | paid | failed)
- paidAt
Key principle:
Subscription state changes ONLY after payment confirmation (via webhook).
🔐 Tap API Client (Centralized & Secure)
We isolate Tap API communication in a single client:
ts
const tapAxios = axios.create({
baseURL: "https://api.tap.company/v2",
headers: {
Authorization: `Bearer ${process.env.TAP_SECRET_KEY}`,
"Content-Type": "application/json",
},
});
This avoids:
- Token leaks
- Duplicate logic
- Inconsistent headers
💳 Creating a Charge (Backend-Controlled)
When a user activates or upgrades a subscription:
- Create subscription & pending payment in DB
- Create a Tap charge
- Redirect user to Tap checkout
ts
export const createTapCharge = async (
amount: number,
subscriptionPaymentId: string,
customer: Customer,
redirectUrl: string
) => {
return tapAxios.post("/charges", {
amount,
currency: "OMR",
customer,
source: { id: "src_card" },
redirect: { url: redirectUrl },
metadata: {
subscriptionPaymentId,
type: "subscription",
},
});
};
Why metadata matters
We store subscriptionPaymentId inside Tap metadata.
This allows:
- Webhook → DB mapping
- Idempotent processing
- No guessing which payment belongs to which subscription
🔁 Redirect-Based Flow (User Experience)
After charge creation, backend returns:
json
{
"paymentUrl": "https://tap.company/checkout/...",
"chargeId": "chg_xxx"
}
Frontend simply redirects the user.
🪝 Webhooks: The Source of Truth
Never trust frontend redirects alone.
Why Webhooks?
- Users may close the tab
- Network failures happen
- Redirects can be spoofed
Secure Webhook Verification
Tap signs every webhook payload.
ts
const expectedSignature = crypto
.createHmac("sha256", TAP_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
Only verified requests are processed.
🔄 Webhook Processing Logic
On webhook receipt:
- Verify signature
- Extract
subscriptionPaymentId - Update payment status
- Update subscription if payment succeeded
ts
if (status === "CAPTURED") {
await payment.update({ status: "paid", paidAt: new Date() });
await subscription.update({
status: "active",
subscriptionType: "paid",
endDate: nextYear,
});
}
Important rule:
Subscription state is updated ONLY inside webhook logic.
📈 Subscription Upgrade with Proration
Upgrades are prorated based on remaining time:
ts
const remainingYears =
(subscription.endDate.getTime() - Date.now()) /
(365.25 * 24 * 60 * 60 * 1000);
const proratedAmount =
additionalUsers * UNIT_PRICE * remainingYears;
This creates:
- A new pending payment
- A new Tap charge
- No disruption to current billing cycle
📉 Downgrades (Deferred)
Downgrades:
- Apply on next renewal
- Do NOT refund automatically
- Prevent abuse near renewal date
ts
if (isWithinLockoutWindow(subscription.endDate)) {
throw new Error("Cannot downgrade near renewal");
}
🧾 Invoice-Based Billing (Enterprise Ready)
For corporate customers:
- No Tap charge created
- Payment marked as
invoice - Admin manually confirms payment
ts
await SubscriptionPayment.create({
paymentMethod: "invoice",
status: "paid",
});
This allows:
- Offline billing
- Bank transfers
- Enterprise workflows
🚫 Automatic Suspension for Non-Payment
A scheduled job checks overdue subscriptions:
ts
if (nextPaymentDue < cutoffDate) {
await subscription.update({ status: "suspended" });
}
This ensures:
- Fair enforcement
- Predictable access control
- No manual intervention
🔐 Authorization & Safety
Critical actions are protected:
-
Only Account Owners can:
- Activate plans
- Upgrade/downgrade
All subscription updates use DB transactions
Payments are immutable records
✅ Key Takeaways
✔ Backend owns all payment logic
✔ Webhooks are the source of truth
✔ Metadata connects payments to business logic
✔ Subscriptions ≠ payments
✔ Design for upgrades, failures, and audits
🚀 Final Thoughts
Tap Payments integrates cleanly into modern SaaS backends when you treat payments as state machines, not API calls.
If you’re building:
- Multi-tenant SaaS
- Seat-based pricing
- Annual subscriptions
- Enterprise billing
This architecture will scale with confidence.
Top comments (0)