For the complete documentation index, see llms.txt. This page is also available as Markdown.

SaaS Developers with Subscription Products

Fungies for SaaS โ€” Subscriptions, Custom Fields, Webhooks & Customer Portal

A complete walk-through for adding subscription billing, app-specific metadata capture, real-time event handling, and self-serve subscription management to any SaaS, using Fungies as the merchant-of-record and Stripe under the hood.


What you'll build

A Next.js (App Router) SaaS that:

  1. Sells a monthly subscription plan from a hosted Fungies checkout.

  2. Captures a custom field (e.g. playerId, tenantSlug, seat_count) at checkout so each subscription is tied to the right account in your DB.

  3. Listens to webhooks to provision access on payment_success, renew on subscription_interval, and revoke on subscription_cancelled / payment_refunded.

  4. Deep-links users to Fungies' hosted Customer Portal so they can update their card, change plans, and cancel without you building a billing UI.

Total reading time: ~25 min. Total integration time: a focused afternoon.


Prerequisites

Need

Where

Fungies workspace + Subscription product with at least one recurring offer

help-center/getting-started

Stripe Connect linked to your Fungies workspace

help-center/stripe-connect

Public HTTPS URL for webhooks (use ngrok http 3000 in dev)

ngrok or Cloudflare Tunnel

Node 20+ and a Next.js 15 app (or any Node server โ€” examples are framework-agnostic enough)

โ€”

Set these env vars in .env.local:

Auth note (verified live): every API call requires BOTH x-fngs-public-key AND x-fngs-secret-key headers, even for GET calls. Missing either โ†’ 401 "API key is invalid". Special chars (+, =, /) in keys are sent verbatim โ€” no URL encoding.


Step 1 โ€” Define your subscription product

In the Fungies dashboard:

  1. Products โ†’ Add product โ†’ type: Subscription.

  2. Add an Offer with recurringInterval: month, recurringIntervalCount: 1, your price (e.g. 999 cents = โ‚ฌ9.99) and currency.

  3. Optionally set a trialInterval for free trials.

Verify it from the API:

Empirical gotcha: GET /v0/products (no /list suffix) returns 404 "Can not GET /v0/products". Always use /list. Same applies to /v0/offers/list, /v0/orders/list, /v0/payments/list, /v0/discounts/list, /v0/elements/checkout/list.

Then grab the offer details:

You'll get something like:

price: 999 is in the smallest currency unit (โ‚ฌ9.99). Don't divide on the way in.

Save that offer UUID โ€” you'll use it everywhere.


Step 2 โ€” Add a custom field for app metadata

This is how you bind a Fungies order to a record in your DB.

In the dashboard: Products โ†’ Project โ†’ Custom Fields โ†’ Add field.

Example for a game-style SaaS:

For a multi-tenant B2B SaaS use tenantSlug or workspace_id.

CRITICAL gotcha (verified live): custom fields have two identifiers:

  • A string key (the label slug) used by the JS SDK.

  • A UUID used by the Checkout Elements API.

The UUID is only visible in the dashboard. Pre-filling with an unknown id silently succeeds (200 OK) but the value is dropped โ€” nothing renders on checkout, nothing comes back in webhooks. You will spend a sad afternoon debugging this if you don't read this paragraph twice.

Find the UUID by visiting the field in the dashboard URL bar, or by inspecting the rendered checkout DOM after creation.


Step 3 โ€” Open the checkout from your app

You have two production-grade options. Pick one.

app/(marketing)/pricing/CheckoutButton.tsx:

That's it. The SDK opens the hosted checkout in an overlay, the customer pays, your webhook fires.

Path B โ€” Server-minted Checkout Element

Use this when you want a stable shareable URL (email blast, in-app link) that already has the custom field baked in.

app/api/checkout/route.ts:

Two important caveats (verified live):

  1. There is no archive/delete endpoint for elements โ€” every one you mint persists forever in /v0/elements/checkout/list. Mint per-user (or per-tenant) and reuse, not per-click.

  2. GET /v0/elements/checkout/{id} returns 404. Store the id when you create it.


Step 4 โ€” Receive and verify webhooks

In Developers โ†’ Webhooks, add an endpoint pointing to https://yourapp.com/api/fungies/webhook and subscribe to:

  • payment_success

  • payment_refunded โ† canonical name (the docs occasionally show payment_refund โ€” that's a typo)

  • payment_failed

  • subscription_created

  • subscription_interval

  • subscription_updated

  • subscription_cancelled

Copy the secret it generates into FUNGIES_WEBHOOK_SECRET.

app/api/fungies/webhook/route.ts:

Webhook payloads carry MORE data than REST GETs. Specifically data.items[].customFields and the customer's email are only in the webhook โ€” GET /v0/orders/{id} does not return items[]. Do not rely on REST polling as a "missed events" fallback for attribution.

Always use the raw request body for signature verification. If you let your framework JSON-parse first, the bytes change and HMAC fails.


Step 5 โ€” Provision and revoke access

Drive your access state from the events:

Subscription lifecycle reference (from .kb/api/subscriptions-endpoints.qmd):


Step 6 โ€” Programmatic subscription control (admin actions)

When your support team needs to act on a subscription from your own admin UI, use the Subscriptions API directly:

Action

Call

Inspect

GET /v0/subscriptions/{id}

Update (seats / amount)

PATCH /v0/subscriptions/{id}/update

Charge immediately

POST /v0/subscriptions/{id}/charge

Cancel

PATCH /v0/subscriptions/{id}/cancel

Pause billing only

PATCH /v0/subscriptions/{id}/pause-collection

Example โ€” change seat count:

Pausing payment collection does not pause the subscription period โ€” the customer keeps their access, you just stop charging. Useful for goodwill credit.


Step 7 โ€” Wire up the Customer Portal (self-serve)

You don't need to build a billing UI. Fungies hosts one per store at the path /portal on your storefront domain (for example: https://azzeki.com/portal).

The flow your end-users go through:

  1. They click a Manage billing link in your SaaS that points to https://<your-store>/portal.

  2. They sign in with the same email they used at checkout (magic link).

  3. They land on the Customer Portal showing all their subscriptions, orders, invoices and saved payment methods.

  4. They click Manage Subscription on any active row to:

    • Update payment method (card / wallet).

    • Change plan (only if you've configured Plans in the Subscriptions dashboard).

    • Cancel (with confirmation) or reactivate a previously cancelled one.

    • Download invoices / receipts with tax breakdowns.

Deep-link from your SaaS:

You can also pre-fill the customer's email so they skip typing it before the magic-link step:

Path is /portal on every Fungies storefront โ€” works on both custom domains (e.g. yourstore.com/portal) and the default *.app.fungies.io/portal. No theme overrides this.

States the portal exposes (mirror these in your own UI when you read your DB):

  • Active โ€” running with a valid payment method.

  • Trialing โ€” in free trial.

  • Cancelled โ€” scheduled for termination at period end.

  • Past Due โ€” payment failed, in grace period.


Step 8 โ€” Seller-side editing & pausing (you, not the customer)

For ops actions you do on behalf of a customer:

  1. Dashboard โ†’ Transactions โ†’ Subscriptions lists every subscriber.

  2. Click Edit on a row to change Quantity (seats) or Amount (price-per-seat). A drawer slides in.

  3. Click Pause Payment Collection to stop billing without ending the period โ€” useful when a customer disputes a charge or you want to give a credit. You'll be presented with options for how long to pause.

Same operations are available via API (Step 6) if you want to expose them in your own admin tools.


Step 9 โ€” Test the whole loop locally

  1. Copy the ngrok HTTPS URL into the Fungies dashboard webhook config (e.g. https://abc123.ngrok.app/api/fungies/webhook).

  2. Open your /pricing page, click Subscribe, complete a real test payment with a Stripe test card.

  3. In your terminal you should see the event sequence:

  1. Trigger a refund from the Fungies dashboard โ†’ expect payment_refunded.

  2. Cancel the test subscription from the dashboard โ†’ expect subscription_cancelled.

If something doesn't show up, check the Webhook deliveries log in the dashboard โ€” failed deliveries are listed there with response bodies. See .kb/developers/webhooks-test.qmd.


Production checklist

  • HTTPS only; HSTS on.

  • Webhook signature verification on, using raw body buffer.

  • Idempotency keyed on event.idempotencyKey (UUID), persisted in Postgres / Redis with a unique index.

  • Webhook handler returns 2xx in <1s; heavy work on a queue.

  • event.testMode === true events are quarantined to your staging DB.

  • Retry your downstream side-effects with backoff; Fungies will retry 5xx for you.

  • Custom field UUIDs stored in env vars per environment (staging vs prod have different UUIDs).

  • One webhook endpoint per concern when complexity grows (multiple endpoints are supported โ€” useful when adding e.g. an affiliate platform later).

  • Secrets rotated quarterly; never log raw keys or signatures.


Troubleshooting matrix

Symptom

Likely cause

Fix

401 "API key is invalid" on every call

Missing one of the two headers

Send both x-fngs-public-key and x-fngs-secret-key

404 "Can not GET /v0/products"

Used the wrong URL pattern

Append /list (e.g. /v0/products/list)

Custom field empty on rendered checkout

Pre-filled with unknown id (string key via Elements API, or UUID via SDK)

SDK uses string key; Elements API uses UUID. Match the path.

Webhook never fires

Endpoint not public, returns non-2xx, or wrong events selected

Open dashboard โ†’ Webhook deliveries; inspect last attempt

Signature mismatch

Body was JSON-parsed before HMAC

Use raw buffer; in Next.js use req.arrayBuffer()

subscription_interval missing

Trial still running, or you didn't subscribe to that event

Check offer has no active trial; re-check event subscriptions

data.items[] is undefined

You called GET /v0/orders/{id} instead of reading the webhook

Items are webhook-only; persist them on receipt

GET /v0/elements/checkout/{id} 404

No public single-element get

Use /list and filter by id, or store the id at create time

POST /v0/discounts/create rejects amount: 0

Minimum discount is 1%

Use a real discount or use a different attribution mechanism

Last updated