Developer
enopsy.com
DocumentationDeveloperBilling Integration

Tenant Onboarding & Billing Integration Guide

How a new tenant connects their platform to our multi-tenant billing engine — Stripe setup, catalog configuration, checkout integration, and webhooks — so they can sell subscriptions and one-time products to their own customers.

Prepared from the current backend implementation (Node.js / Express / MongoDB, per-tenant database architecture). Document version 1.0.

1. Architecture at a Glance

This platform is a database-per-tenant billing engine: one Node.js/Express backend serves every tenant, but each tenant's data (catalog, orders, invoices, customers, payment gateway credentials) lives in its own MongoDB database. A request is routed to the correct tenant database by resolving a tenant from the incoming request, then binding that tenant's DB connection for the rest of the request lifecycle.

How a request finds "your" data
  1. Public storefront calls (/public-api/*) are resolved by the request's Origin / Referer / Host header, matched against the domain you registered with us.
  2. Your own staff/admin dashboard calls (/admin/*) are resolved by a JWT (cookie/bearer) or an x-tenant-id header.
  3. Your end-customers' self-service calls (/client/*) are resolved the same way, then further scoped to the logged-in customer's own records.

Important: tenant/database provisioning itself is not exposed through a public signup API today — it's a one-time setup our platform team performs (Section 4). Everything after that (catalog, checkout, self-service, webhooks) is what this guide walks you through.

Stripe model: bring your own account (not Stripe Connect)

Each tenant uses their own standalone Stripe account — you generate your own Stripe secret key and publishable key and hand them to us to store (encrypted) against your tenant. This is not Stripe Connect: we never call Stripe with on_behalf_of, application fees, or connected-account IDs. From Stripe's point of view, charges simply happen on your account, using your keys, initiated by our backend on your behalf.

BackendNode.js / Express, MongoDB (Mongoose)
Tenant isolationOne MongoDB database per tenant
Payment processorStripe — one Stripe account per tenant (your own account, your own keys)
Public integration surface/public-api/* (your storefront) and /client/* (your customers' self-service portal)
Your admin surface/admin/* (catalog, orders, settings — your staff dashboard)
Hosted checkout UI ("Payment App")A separate React/Vite single-page app, deployed once and shared by every tenant — see Section 8.1
Inbound Stripe webhookPOST /api/stripe/webhook (single shared endpoint, see Section 12)
Outbound webhooks to youConfigurable per tenant, see Section 11

There are three moving parts to this platform, and this guide covers how your integration touches each:

  1. The billing backend — the API described throughout this guide.
  2. Your own site — where you'll wire up the Admin API (catalog) and Client Portal API (customer self-service).
  3. The Payment App — a pre-built, Stripe-Elements-powered checkout page we host. You don't need to build your own payment form; you link/redirect your customers to it (Section 8.1). It is a separate codebase/deployment from the billing backend.

2. Key Concepts & Glossary

TermWhat it means here
TenantYour organization on the platform. Has its own MongoDB database, its own Stripe account, its own catalog and customers.
Project / Product OriginInside your tenant, a "project" (stored as a projectgeneralsettings document) represents one sellable storefront/product line — it holds your registered origin domain, enabled payment gateway(s), currency settings, and outbound webhook configuration. Most tenants have one project; you can have more than one if you run multiple storefronts.
GatewayA payment method configuration record (paymentgateways collection) — for Stripe, this stores your encrypted secret key and publishable key. A Plan references the gateway it should charge through.
ProductA catalog entry describing what you're selling and its pricing shape (free / one-time / recurring), independent of billing mechanics.
PlanThe sellable, billable entity your customers actually purchase — has price(s) per currency, a billing interval, trial settings, feature entitlements, and optional add-ons. Plans and Products are first-party records only — we do not create matching Product/Price objects in Stripe. Stripe only ever sees a raw charge amount at the moment of payment.
OrderOne purchase transaction — a plan purchase, an add-on purchase, or a one-time product purchase. Drives recurring billing state for subscriptions.
SubscriptionThe active/ongoing state of a recurring Order (status, next billing date, feature usage). This is our own subscription object — there is no corresponding Stripe Subscription object; renewals are charged by us re-using the saved payment method (Section 12).
InvoiceOur own PDF-generated invoice/receipt record tied to an Order. Not a Stripe Invoice object.
TransactionA record of one payment attempt/result — success, failure, refund, chargeback — carrying the Stripe PaymentIntent/Charge IDs for reconciliation.

3. Onboarding Checklist (Quick Start)

End-to-end sequence from "we've agreed to onboard you" to "your customers can pay you."

#StepOwner
1Platform team creates your tenant database and base project recordUs
2You generate a Stripe secret key + publishable key on your own Stripe account and send them to us securelyYou
3We store your Stripe keys (encrypted) as a Gateway record and link it to your projectUs
4You tell us your storefront domain(s) and base currencyYou
5We register your domain as your project's origin so /public-api/* resolves to your tenantUs
6You (or we, on request) create your Products/Plans via the Admin API — pricing, currencies, trials, entitlementsYou/Us
7You link your customers to the hosted Payment App for checkout (or build your own UI against the Purchase API)You
8You wire your customer account pages to the Client Portal API (plan changes, cancellation, invoices, saved cards)You
9You give us a webhook URL (and choose an auth mode) to receive payment/subscription/invoice eventsYou
10Go live — test a real card in Stripe test mode end-to-end before flipping to live keysYou

4. Step 1 — Platform Provisioning

This step is performed by our platform/ops team — there is currently no public self-service signup endpoint for creating a new tenant. We will:

  1. Create a TenantRegistry entry (your tenant's unique name, display name, and the connection details for your dedicated database).
  2. Provision your dedicated MongoDB database.
  3. Create your project (projectgeneralsettings) document — the record that will hold your storefront origin, currency, enabled gateway(s), and webhook config from later steps.
What we need from you to start this step: your organization/business name, a technical contact, and the domain(s) your storefront will run on (can be finalized in Step 3).

5. Step 2 — Connect Your Stripe Account

We charge your customers using your Stripe account. You do not need to give us access to your Stripe dashboard — only two API keys.

Stripe secret keysk_test_... for testing, sk_live_... to go live
Stripe publishable keypk_test_... / pk_live_... — used by your checkout page to mount Stripe.js/Elements and tokenize cards

We store both keys encrypted in a paymentgateways record (provider: "stripe") inside your tenant database and link it to your project so Plans and Orders resolve back to it automatically at charge time.

How the platform actually calls Stripe. At the moment of purchase, the backend picks your Gateway's decrypted secret key and calls the Stripe API directly with your key — there is no Stripe Connect, no on_behalf_of, no application fee. The Stripe API calls made on your behalf are:
  • stripe.customers.create — one Customer per payer, first purchase
  • stripe.paymentMethods.attach — attaches the card token from your checkout page
  • stripe.paymentIntents.create (confirm: true) — the actual charge
  • stripe.paymentIntents.confirm — resumes a charge after 3D Secure (Step 6)
  • stripe.paymentIntents.create (off_session: true) — automatic recurring renewal charges (Section 13)
  • stripe.refunds.create / stripe.charges.* — refunds issued from your admin dashboard
We never call stripe.products.*, stripe.prices.*, stripe.checkout.sessions.*, stripe.subscriptions.*, or stripe.coupons.* — your catalog and subscription state live only in our database.

Multiple payment methods per project: a project can have more than one active Gateway (e.g. Stripe plus an offline "Bank Transfer" gateway). When a Plan doesn't pin a specific gateway, the resolver prefers, in order: an active Stripe gateway → any active online gateway → any active gateway with a key.

6. Step 3 — Register Your Storefront Origin & Currency

Your public checkout pages (/public-api/*) are resolved to your tenant purely by request origin — no API key is sent on these calls. Tell us the exact domain(s) your storefront runs on (e.g. https://shop.yourcompany.com) so we can register it as your project's origin.

Security note for your integration. Because /public-api/* trusts the Origin / Referer / Host header rather than a bearer token, it is designed to be called from a browser on your registered domain. If you plan to call it server-to-server (not from a browser), you must set a matching Origin / Host header yourself — treat this as origin-based routing, not as an authentication mechanism, and don't rely on it to keep data private beyond routing.

Bootstrapping the tenant ID client-side

GET /public-api/tenant/fetch-tenant?origin=https://shop.yourcompany.com

200 OK
{
  "_id": "6512f...e91",
  "tenant_unique_name": "yourcompany"
}

Also confirm your supported currency list:

GET /public-api/settings/global-settings/general-settings/currency-list?projectId={projectId}

7. Step 4 — Build Your Product & Plan Catalog

Catalog management happens on your Admin API (/admin/*), authenticated as your own staff user (JWT). This is a plain database write on our side — no Stripe Product/Price is created; Stripe only sees a bare amount + currency when a customer actually pays.

Core fields you'll set on a Plan

FieldMeaning
billingType1 = one-time, 2 = recurring
billingIntervalRecurring cadence in months: 1, 3, 6, 12, 24, 36
currency / availableCurrencies / pricesBase currency plus a per-currency price book
trialPeriod / trialDaysFree trial before first charge
featuresReference to a Feature Category — the entitlements (usage limits, on/off flags) a subscriber gets
addons[]Optional extra purchasable items alongside the plan
gatewayWhich Gateway (Section 5) this plan charges through
slugThe public identifier your storefront/API calls use to reference this plan

Where catalog is managed

MethodPathPurpose
POST/admin/productCreate a Product
POST/admin/plansCreate a Plan (pricing, cycles, features, addons, gateway)
GET/public-api/plan?origin=List active plans for your storefront (public, read-only)
GET/public-api/plan/:planSlug/featuresFeature list for one plan (public)
GET/public-api/plan/:planSlug/quote?origin=&currency=&cycleId=Display-only price quote for a plan in a given currency/cycle. The real amount is re-derived server-side at checkout — never trust a client-computed price.

Discounts. Coupons are entirely first-party (tenant-scoped rules, redemption ledger, usage caps) — never synced to Stripe as coupons or promotion codes. The discount is computed in our database and only the final, already-discounted amount is sent to Stripe as the PaymentIntent amount.

8. Step 5 — Integrate Checkout on Your Site

There are three integration paths, in order of how much frontend work they require. Most tenants should just use the hosted Payment App (8.1) — it's a finished checkout page we already built and host; you generate a link to it, and Stripe Elements/3D Secure/receipt are all handled for you.

8.1 — Recommended: Redirect to the Hosted Payment App (zero frontend code)

  1. Create a payment link for a plan from your admin dashboard (or via API):
    POST /public-api/payment-links
    {
      "sourceType": 3,
      "sourceReferenceSlug": "pro-monthly"
    }
  2. The backend builds a checkout URL against the Payment App's shared domain, e.g. https://pay.ourdomain.com/pay?payload=<encrypted> — the encrypted payload already carries the plan, price, currency, your productOrigin, and your Stripe publishable key, so the Payment App needs no separate login or tenant lookup to render correctly.
  3. Link, email, or button your customer straight to that URL (or open it in an iframe/new tab from your site) — no Stripe.js integration required on your side.
  4. The Payment App collects the card with Stripe Elements, calls /public-api/purchase and /public-api/purchase/confirm itself (Section 9), and on success navigates to its own /payment-success?summaryRef=... receipt page.
What to give us: nothing beyond Steps 2–4 (your Stripe keys, origin, and catalog) — payment-link URLs are generated automatically from those. If you need your own domain in front of the Payment App (e.g. pay.yourcompany.com) or custom branding on the checkout page, ask our platform team — today it's one shared, unbranded deployment used by every tenant.
What the Payment App does NOT do: it has no plan-listing/pricing page and no subscription-management page — it only renders the single checkout form for the plan/order encoded in the payload it's given. Build your pricing page and your customers' "manage my subscription" page on your own site using the Public API (Section 7) and the Client Portal API (Section 10); use the Payment App purely as the payment step.

8.2 — Custom-branded checkout: Payment Links + your own UI

If you want full control over the checkout page's look and feel (rather than the shared Payment App), you can build your own form and still use the same publishable-key lookup:

  1. Create the payment link as above, then validate the slug from your own checkout page to get plan details and the Stripe publishable key:
    GET /public-api/payment-links/validate/:slug
    
    200 OK
    {
      "plan": { "...": "..." },
      "provider": "stripe",
      "publicKey": "pk_test_...",
      "gatewayId": "..."
    }
  2. Collect the card with your own Stripe Elements integration client-side (card number never touches our servers), then submit the resulting PaymentMethod/token to the Purchase API below.

8.3 — Direct: Purchase API (fully headless / custom checkout UI)

A single unified endpoint handles orders, standalone invoices, and plan purchases, distinguished by sourceType:

sourceTypeMeaningEffect on success
1OrderInvoice + Transaction + card charge
2InvoiceTransaction + card charge against an existing invoice
3PlanOrder + Invoice + Subscription + Transaction (this is what powers a new subscription)
POST /public-api/purchase
{
  "productOrigin": "6512f...e91",
  "customerInfo": { "email": "[email protected]", "name": "Jane Doe", "mobile": "+1..." },
  "paymentMethodId": "pm_1P...",
  "planInfo": {
    "paymentSourceDetails": {
      "sourceType": 3,
      "sourceReferenceSlug": "pro-monthly"
    },
    "metadata": { "your_internal_customer_id": "cus_local_123" }
  }
}

// success
200 OK
{ "status": "success", "payment_intent": { "...": "..." }, "receipt": { "summaryRef": "..." } }

// card requires authentication (3D Secure) — see Step 6
200 OK
{ "status": "requires_action", "clientSecret": "pi_..._secret_...", "receipt": { "summaryRef": "..." } }

// card declined
402 { "status": "payment_failed", "message": "..." }
No publishable-key lookup on this path. The direct Purchase API does not itself return a Stripe publishable key anywhere in the plan-listing or quote responses. If you build a fully custom checkout UI against /public-api/purchase directly (skipping Payment Links), you'll need to obtain your own publishable key out of band (you already have it — it's the one you generated in Step 2) rather than expecting our API to hand it back to you.

Pre-flight helpers

MethodPathPurpose
GET/public-api/purchase/stripe-minimum?currency=&amount=Validate the amount clears Stripe's minimum chargeable amount for that currency before you show a "Pay" button.
GET/public-api/purchase/summary/:slugPost-payment summary/receipt data (slug = receipt.summaryRef from the purchase response) — good for a "thank you" page.
POST/public-api/purchase/addon, /addon/confirmPurchase an add-on alongside/after a plan.
Do not use: POST /public-api/purchase/subscription/update and the underlying subscription.controller.js handler are non-functional placeholders (hardcoded plan/amount) — not wired to real traffic. For changing an existing subscriber's plan, use the Client Portal endpoint in Section 10 (/client/plan/:slug/change-plan) instead.

9. Step 6 — Handle 3D Secure / SCA

When Stripe requires additional authentication, the Purchase API returns status: "requires_action" with a PaymentIntent clientSecret. Your checkout page must:

  1. Call stripe.confirmCardPayment(clientSecret) client-side (Stripe.js) to complete the 3DS challenge.
  2. Then call our confirm endpoint to finalize the order on our side:
    POST /public-api/purchase/confirm
    { "paymentIntentId": "pi_...", "receiptRef": "..." }

Only after this confirm step succeeds are the Order/Invoice/Subscription records created — a PaymentIntent stuck in requires_action that the customer abandons produces no order.

10. Step 7 — Give Customers Self-Service (Client Portal API)

Once a customer has an account with you, everything under /client/* is scoped to them — authenticated via a JWT (cookie <projectToken>_auth_token, an Authorization / auth-token header, or a ?token= query param).

MethodPathPurpose
GET/client/planList plans available to this customer
GET/client/plan/subscriptionThe customer's current subscription
GET/client/plan/subscription/dashboardUsage/entitlement dashboard data
GET/client/plan/:slug/change-plan/optionsValid upgrade/downgrade targets from the current plan
POST/client/plan/:slug/change-planChange plan (handles proration)
POST/client/plan/:slug/change-plan/apply-zeroApply a plan change that costs $0 (e.g. downgrade covered by credit)
POST/client/plan/:slug/cancelCancel the subscription
GET/client/invoices, /client/invoice/:idList / fetch invoices
GET/client/invoice/download-pdf, /download-receiptDownload invoice/receipt PDF
GET / POST/client/cardList / add a saved card
PATCH/client/card/:id/primaryMake a card the default payment method
DELETE/client/card/:idRemove a saved card
GET/client/order, /client/transactionOrder / payment history
PATCH/client/profile/preferred-currencyUpdate the customer's display/billing currency preference

11. Step 8 — Receive Events From Us (Outbound Webhooks)

Tell us an HTTPS endpoint on your side and we'll POST you an event payload whenever something billing-relevant happens. Delivery is fire-and-forget — a failure on your end never blocks or rolls back the purchase.

Events we can send

EventFired when
payment.succeededA charge (order, invoice, or plan purchase) succeeds
payment.failedA charge attempt fails
refund.succeededA refund is issued
subscription.activatedA new subscription becomes active
subscription.cancelledA subscription is cancelled
invoice.createdAn invoice is generated
invoice.paidAn invoice is marked paid
order.createdA new order is recorded

Choosing how we authenticate to you

ModeWhat you receive
shared_secret (default)A header (default name x-webhook-secret, or a custom header name you specify) carrying a secret you gave us — check it matches on every request.
bearerAuthorization: Bearer <your secret>
no_authNo auth header at all — only use this on an endpoint you fully trust to be unguessable/internal.
Note: an hmac_sha256 signing mode is reserved in the config schema but not implemented yet — don't select it.
What to give us: your webhook URL, which events you want (or "all"), and your chosen auth mode + secret/header name.

12. Step 9 — Our Inbound Stripe Webhook

Separately from Section 11, our backend itself listens for events coming from Stripe, at a single fixed endpoint: POST /api/stripe/webhook (verified via Stripe-Signature header).

What it actually handles today

Stripe eventWhat we do with it
charge.dispute.createdFlag the matching Transaction as disputed, hold for manual review, email our admin
charge.dispute.closedSettle the dispute record; if you lost the dispute, mark the linked invoice REFUNDED
charge.refundedAuto-book a Refund record and reconcile ledgers (deduplicated by refund ID)

Everyday purchase success/failure is not driven by a Stripe webhook at all — the Purchase API (Section 8) confirms the PaymentIntent synchronously in the same request and writes the Order/Invoice/Subscription immediately. The Stripe webhook exists specifically to catch asynchronous events (disputes, refunds initiated from your Stripe dashboard) that can't be observed synchronously.

Known limitation to plan around. Signature verification currently uses one webhook signing secret shared across the whole platform, not a secret per tenant Stripe account. Since Stripe assigns a distinct signing secret to each webhook endpoint you register in your own Stripe dashboard, registering your Stripe account's own webhook endpoint against this shared URL will not verify correctly on its own — coordinate with our platform team before relying on dispute/refund webhook processing for your account; self-serve per-tenant webhook secrets aren't exposed yet.

13. Money Mechanics

Invoices & receipts

Invoices are our own PDF documents (EJS template rendered to PDF via a headless browser), not Stripe Invoice objects — there is no stripe.invoices.* call anywhere in the flow.

Transactions

Every payment attempt (success, failure, refund, chargeback, or a wallet credit/debit) is recorded as a Transaction, carrying the Stripe PaymentIntent ID, Charge ID, and Customer ID for reconciliation against your Stripe dashboard.

Wallet credit

Each customer has one internal wallet (balance / credit balance / hold balance, integer minor units). It's a pure internal ledger — crediting a wallet never calls Stripe. Wallet balance is drawn down automatically before a card is charged where applicable.

Coupons

Entirely first-party — rules, eligibility, usage caps, and redemption tracking live in our database. Stripe is only ever shown the final discounted amount, never a coupon/promotion code object.

Multi-currency

You can charge in a "presentment" currency different from your own base/settlement currency. We persist the exchange rate and its source at the time of charge, and re-verify the PaymentIntent's actual currency/amount server-side before marking anything paid — mismatches are rejected rather than silently accepted. Zero-decimal currencies (e.g. JPY, KRW) and three-decimal currencies (e.g. KWD, BHD, OMR) are handled with Stripe's minor-unit rules in mind; always check /public-api/purchase/stripe-minimum before charging a small amount in an unfamiliar currency.

Recurring billing (subscriptions)

This is emulated by us, not Stripe. There is no Stripe Subscription object anywhere in this system. Instead, a scheduled job re-charges the customer's saved payment method (paymentIntents.create with off_session: true) on each subscription's stored nextBillingDate, and advances that date on success. After repeated failed renewal attempts the subscription is suspended, and once retries are exhausted the underlying order is moved to a terminal cancelled state.

14. API Reference Index

Public Storefront API — /public-api/* (origin-resolved, no token)

MethodPathPurpose
GET/public-api/tenant/fetch-tenantResolve tenant by origin
GET/public-api/planList active plans
GET/public-api/plan/:planSlug/featuresPlan feature list
GET/public-api/plan/:planSlug/quoteDisplay price quote
GET/public-api/input-options/countries, /statesAddress dropdown data
GET/public-api/settings/.../currency-listSupported currencies
POST/public-api/payment-linksCreate a hosted checkout link (plan only)
GET/public-api/payment-links/validate/:slugValidate link, get plan + publishable key
POST/public-api/purchaseCreate payment (order / invoice / plan)
POST/public-api/purchase/confirmConfirm after 3D Secure
GET/public-api/purchase/summary/:slugPost-payment summary
GET/public-api/purchase/stripe-minimumPre-validate minimum chargeable amount
POST/public-api/purchase/addon, /addon/confirmAdd-on purchase

Client Portal API — /client/* (customer JWT)

See the full table in Section 10 above.

Payment App (hosted checkout SPA — separate deployment)

PathPurpose
/pay?payload=<encrypted>The checkout form itself — Stripe Elements, coupon entry, 3D Secure. The payload is generated by the backend (Section 8.1); do not construct it yourself.
/payment-success?summaryRef=...Post-payment receipt page, reached automatically after checkout completes.
/privacy-policyStatic privacy policy page.

Your Admin API — /admin/* (staff JWT / x-tenant-id)

MethodPathPurpose
POST/admin/productCreate a Product
POST/admin/plansCreate a Plan
GET/admin/selectOptions/card/gatewaysList configured payment gateways
GET/admin/selectOptions/card/gateway-public-keyFetch a gateway's publishable key (staff-only)
GET/admin/settings/global-settings/projectRead your project settings (origin, gateways, webhooks)
GET/admin/transactions, /admin/order, /admin/invoiceBack-office views of orders/invoices/transactions
POST/admin/wallet/addAdd wallet credit to a customer
POST/admin/couponCreate a discount coupon

Project settings (origin, gateway selection, outbound webhook config) are currently read-only via API — changes to these go through our platform team until a write endpoint is exposed.

15. Status Code Reference

Order status

ValueMeaning
1Pending
2Active
3Fraud
4Cancelled
5Incomplete
6Complete

Invoice status

ValueMeaning
1Unpaid
2Paid
3Cancelled
4Overdue
5Refunded
6Partially paid
7No amount due

Transaction status

ValueMeaning
1Pending
2Processing
3Succeeded
4Failed
5Cancelled

16. Known Limitations & Recommendations

AreaCurrent stateRecommendation
Tenant provisioningManual/internal only, no signup APIBudget lead time with our platform team for new tenants
Stripe catalog syncNo Stripe Products/Prices are ever createdTreat Stripe purely as your processor/statement source, not your source of catalog truth — always use our Plan/Product records for pricing logic
Publishable key on direct Purchase APINot returned by plan-listing or quote endpointsUse the Payment Link flow, or keep your own copy of your publishable key client-side
Inbound Stripe webhook secretOne shared signing secret platform-wideCoordinate with us before depending on dispute/refund webhook automation for your account
/public-api/purchase/subscription/updateNon-functional placeholderUse /client/plan/:slug/change-plan instead
Recurring billingCron-driven off-session recharge, not Stripe SubscriptionsDon't build tooling against Stripe's subscription/invoice objects for renewal state — poll our Order/Subscription status instead, or subscribe to our outbound webhooks (Section 11)
Payment App brandingOne shared, unbranded deployment used by every tenant — no per-tenant theming or custom domain out of the boxUse it as-is for a fast launch; ask the platform team if you need white-label branding, or build your own checkout with Section 8.2/8.3 instead
Payment App origin/tenant auto-detectionImplemented in the code but currently disabled — the app relies entirely on the encrypted payload link for context, not on your domainAlways launch checkout via a link generated by the backend (Section 8.1) — don't expect the Payment App to resolve your tenant from a bare URL
Payment App scopeCheckout + receipt only — no pricing/plan-listing page and no subscription-management pageBuild those on your own site using the Public API (Section 7) and Client Portal API (Section 10)

This guide reflects the platform's current implementation as of its generation date. Endpoints and behavior may change — contact the platform team for the latest contract before building critical automation against undocumented response fields.