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 (2)
how to get TAP_WEBHOOK_SECRET, can
you help
Just to clarify one detail on the TAP_WEBHOOK_SECRET: Tap doesn't actually give you a separate webhook secret token in the dashboard.
Instead, to secure your webhook endpoint, you will use your standard TAP_TEST_SECRET_KEY (e.g., sk_test_xxxx) to validate the data. When Tap sends a webhook to your controller, it includes a hashstring. Your controller will need to grab the payment details from the payload, mix it with your Tap Secret Key, and verify that it matches Tapβs hash. Check out the 'Validate the webhook (hashstring)' section in that official link I sent you for the exact code implementation!
also keep in mind you also need to add Webhook URL to .env, for example like this:
TAP_WEBHOOK_URL=yourdomain/api/v1/webhooks/tap
developers.tap.company/docs/webhook