Chronodat Subscription Admin

Sign in

Enter your admin user ID and password to view the SPFx integration guide.

Chronodat Admin
Home Portal
Subscriptions Apps Testing Site licences Products License keys Send email Notifications Logs SPFx guide

On this page

Architecture Overview CORS setup Web part properties Tenant ID Subscription service License keys Check access Checkout & portal API reference Production checklist
SharePoint Framework

SPFx subscription integration

Connect your SPFx web part to the Chronodat Subscription API for tenant trial checks, Stripe checkout, and billing portal — all from the browser on SharePoint pages.

Architecture

The Chronodat Subscription API is a Vercel serverless layer that sits between your clients (SPFx web parts, the subscription portal, and the admin dashboard) and the services that hold state — Supabase, Stripe, Resend, and Microsoft Entra ID.

Chronodat Subscription API architecture diagram showing clients, the API layer, Supabase, and external services
End-to-end architecture: clients → API → Supabase, with Stripe, Resend, and Entra ID.

Request flow

  1. Access check. An SPFx web part calls GET /api/subscription/status. The API validates the caller's Entra (AAD) token, looks up the site in spfx_site_installations, and returns trialing / active / expired plus a signed statusToken.
  2. Subscribe. From /subscribe (or the web part), POST /api/subscription/checkout resolves the product's Stripe price from the spfx_products table and returns a Stripe Checkout URL.
  3. Stripe webhooks. Stripe calls POST /api/subscription/webhook; the API updates the site record, logs a row in spfx_subscription_events, and triggers a Resend email.
  4. Offline licensing. POST /api/subscription/activate validates a key from spfx_license_keys, records the activation, and issues a long-lived licenseToken (RS256 JWT) the client verifies offline against the public signing key.
  5. Admin. /admin/* pages authenticate via POST /api/admin/login against spfx_admin_users and use a signed session token to manage products, sites, and license keys.
  6. Observability. Every request is logged to spfx_api_requests and surfaced on the Subscriptions → API requests page.

How it works

Billing is per SharePoint site. Your web part calls this API; Stripe secrets never go in SPFx code.

SharePoint page (https://contoso.sharepoint.com/...) └─ SPFx web part (browser) ├─ GET /api/subscription/status → trialing | active | expired ├─ POST /api/subscription/activate → license key → licenseToken (offline) ├─ POST /api/subscription/checkout → Stripe Checkout ├─ POST /api/subscription/portal → Stripe Customer Portal └─ POST /api/notifications/send → Resend email (optional) Chronodat Subscription API └─ spfx_site_installations (Supabase) + license keys + Stripe webhooks
API base URL:
Set this as Subscription API URL in your web part (no trailing slash).

1. Configure CORS

Set APP_ORIGIN on Vercel so SharePoint origins can call the API from the browser.

ScenarioAPP_ORIGIN
All SharePoint Online customers (recommended)sharepoint-online
Specific tenantshttps://contoso.sharepoint.com,https://fabrikam.sharepoint.com
SPFx workbench + productionsharepoint-online,https://localhost:4322

2. Web part property pane

PropertyTypeDescription
Subscription API URLstringYour API base URL — no trailing slash
Skip subscription checkbooleantrue for dev only; false in production

3. Get tenant context from SPFx

Use the Azure AD tenant ID on every API call. The first status request starts the 14-day trial.

TypeScript
import { WebPartContext } from '@microsoft/sp-webpart-base';

export function getTenantId(context: WebPartContext): string {
  return context.pageContext.aadInfo?.tenantId?.toString() ?? '';
}

export function getUserEmail(context: WebPartContext): string {
  return context.pageContext.user.email;
}

export function getSiteUrl(context: WebPartContext): string {
  return context.pageContext.web.absoluteUrl;
}

4. Subscription service

Add src/services/SubscriptionService.ts to your SPFx project:

SubscriptionService.ts
export interface SubscriptionStatus {
  status: 'trialing' | 'active' | 'past_due' | 'expired';
  hasAccess: boolean;
  trialDaysTotal: number;
  trialDaysRemaining: number;
  trialEndsAt?: string;
  currentPeriodEnd?: string;
  customerEmail?: string;
}

export class SubscriptionService {
  constructor(private readonly apiBaseUrl: string) {
    this.apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
  }

  async getStatus(params: {
    tenantId: string;
    userEmail?: string;
    siteUrl?: string;
  }): Promise<SubscriptionStatus> {
    const qs = new URLSearchParams({ tenantId: params.tenantId });
    if (params.userEmail) qs.set('userEmail', params.userEmail);
    if (params.siteUrl) qs.set('siteUrl', params.siteUrl);

    const res = await fetch(`${this.apiBaseUrl}/api/subscription/status?${qs}`);
    if (!res.ok) {
      const body = await res.json().catch(() => ({}));
      throw new Error(body.error ?? `Status failed (${res.status})`);
    }
    return res.json();
  }

  async startCheckout(params: {
    tenantId: string;
    userEmail: string;
    successUrl: string;
    cancelUrl: string;
    siteUrl?: string;
    tenantName?: string;
  }): Promise<string> {
    const res = await fetch(`${this.apiBaseUrl}/api/subscription/checkout`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(params)
    });
    if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Checkout failed');
    return (await res.json()).url;
  }

  async openBillingPortal(params: { tenantId: string; returnUrl: string }): Promise<string> {
    const res = await fetch(`${this.apiBaseUrl}/api/subscription/portal`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(params)
    });
    if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Portal failed');
    return (await res.json()).url;
  }
}

5. Offline license keys (one key = one site)

Admins generate keys at /admin/license-keys. Each key activates one SharePoint site and returns a long-lived licenseToken JWT for offline access.

Activate
const res = await fetch(`${apiBaseUrl}/api/subscription/activate`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  body: JSON.stringify({
    licenseKey: 'CDAT-DEFA-A1B2-C3D4-E5F6',
    tenantId: getTenantId(context),
    siteUrl: getSiteUrl(context),
    siteId: context.pageContext.site.id?.toString(),
    productSlug: 'default'
  })
});
const status = await res.json();
// status.licenseToken → cache in localStorage for offline verification

Copy the reference client from docs/spfx/OfflineLicenseService.ts in this repository. Install jose in your SPFx project for JWT signature verification.

Test without SPFx: use the hosted portal at /subscribe — it includes license key activation and stores the token in the browser.

Access check order

  1. Verify cached licenseToken offline (signature + expiry)
  2. If unavailable, call GET /api/subscription/status online
  3. If no access, show license key form or Stripe subscribe

6. Check access on web part load

Web part render
const apiUrl = this.properties.subscriptionApiUrl?.trim();
if (this.properties.skipSubscriptionCheck || !apiUrl) return; // dev bypass

const service = new SubscriptionService(apiUrl);
const status = await service.getStatus({
  tenantId: getTenantId(this.context),
  userEmail: getUserEmail(this.context),
  siteUrl: getSiteUrl(this.context)
});

if (!status.hasAccess) {
  // Show paywall — trial expired
  return;
}

// trialing, active, or past_due — render web part

Status → UI mapping

statushasAccessUI
trialingtrueFull access + trial banner + Subscribe
activetrueFull access; optional Manage billing
past_duetrueWarning to update payment
expiredfalseBlock features; Subscribe only

7. Subscribe & manage billing

Checkout redirect
const pageUrl = window.location.href.split('?')[0];

const url = await service.startCheckout({
  tenantId: getTenantId(this.context),
  userEmail: getUserEmail(this.context),
  successUrl: pageUrl,
  cancelUrl: pageUrl,
  siteUrl: getSiteUrl(this.context)
});
window.location.href = url;

Or link users to the hosted portal for testing:

Deep link

            

API reference

MethodPathBody / query
GET/api/subscription/status?tenantId=&siteUrl=&siteId?&productSlug?
POST/api/subscription/activatelicenseKey, tenantId, siteUrl, siteId?, productSlug?
GET/api/subscription/status-signing-keyPublic key for JWT verification
POST/api/subscription/checkouttenantId, siteUrl, userEmail, successUrl, cancelUrl
POST/api/subscription/portaltenantId, siteUrl, returnUrl
POST/api/notifications/sendtenantId, to[], subject, html — requires active trial/subscription; set RESEND_API_KEY + verified RESEND_FROM on Vercel

Status response example

JSON
{
  "status": "trialing",
  "hasAccess": true,
  "trialDaysTotal": 14,
  "trialDaysRemaining": 12,
  "trialEndsAt": "2026-06-28T10:00:00.000Z",
  "customerEmail": "user@contoso.com"
}

Production checklist

  • API deployed with Supabase + Stripe env vars
  • Optional: RESEND_API_KEY + verified RESEND_FROM for SPFx email notifications
  • APP_ORIGIN=sharepoint-online on Vercel
  • Stripe webhook → /api/subscription/webhook
  • Web part Subscription API URL set (no trailing slash)
  • Skip subscription check = off in production
  • Test: new tenant → trialing → checkout → active
  • License keys: generate at /admin/license-keys → activate via SPFx or /subscribe
  • Optional JWT keys for statusToken / licenseToken offline verification
Admin · Chronodat Subscription API