# 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`:

```bash
FUNGIES_PUBLIC_KEY=pub_...
FUNGIES_SECRET_KEY=sec_...
FUNGIES_WEBHOOK_SECRET=whsec_...           # set when you register the webhook
FUNGIES_STORE_URL=https://yourstore.com/   # e.g. azzeki.com — your custom domain or *.app.fungies.io
FUNGIES_API_BASE=https://api.fungies.io
```

> **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:

```bash
curl https://api.fungies.io/v0/products/list \
  -H "x-fngs-public-key: $FUNGIES_PUBLIC_KEY" \
  -H "x-fngs-secret-key: $FUNGIES_SECRET_KEY"
```

> **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:

```bash
curl "https://api.fungies.io/v0/offers/list?take=10" \
  -H "x-fngs-public-key: $FUNGIES_PUBLIC_KEY" \
  -H "x-fngs-secret-key: $FUNGIES_SECRET_KEY"
```

You'll get something like:

```json
{
  "object": "offer",
  "id": "4cab6ea0-7ba5-42eb-bded-128c08a93c8f",
  "price": 999,
  "currency": "EUR",
  "recurringInterval": "month",
  "recurringIntervalCount": 1,
  "trialInterval": null,
  "status": "OPEN"
}
```

> `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:

```
Label:       Player ID
Placeholder: Enter your in-game player ID
Regex:       ^[a-zA-Z0-9_]{3,32}$
```

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.

#### Path A — JS SDK overlay (recommended for SaaS signup flows)

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

```tsx
"use client";
import { useEffect } from "react";

declare global {
  interface Window { Fungies: any; }
}

export function CheckoutButton({ offerId, user }: {
  offerId: string;
  user: { id: string; email: string };
}) {
  useEffect(() => {
    if (!document.getElementById("fungies-sdk")) {
      const s = document.createElement("script");
      s.id = "fungies-sdk";
      s.src = "https://cdn.jsdelivr.net/npm/@fungies/fungies-js@latest";
      s.defer = true;
      document.body.appendChild(s);
    }
  }, []);

  const onClick = () => {
    window.Fungies.Checkout.open({
      checkoutUrl: `${process.env.NEXT_PUBLIC_FUNGIES_STORE_URL}checkout/${offerId}`,
      settings: { mode: "overlay" },
      billingData: { email: user.email },
      customFields: { playerId: user.id },   // ← string key, set in dashboard
    });
  };

  return (
    <button
      onClick={onClick}
      className="rounded-md bg-black px-4 py-2 text-white hover:bg-zinc-800 active:scale-[0.98] transition"
    >
      Subscribe
    </button>
  );
}
```

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`:

```ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { offerId, playerId } = await req.json();

  const r = await fetch(`${process.env.FUNGIES_API_BASE}/v0/elements/checkout/create`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-fngs-public-key": process.env.FUNGIES_PUBLIC_KEY!,
      "x-fngs-secret-key": process.env.FUNGIES_SECRET_KEY!,
    },
    body: JSON.stringify({
      name: `signup_${playerId}_${Date.now()}`,
      offersIds: [offerId],
      customFields: [
        { id: process.env.FUNGIES_CF_PLAYER_ID_UUID!, value: playerId }, // ← UUID, not string key
      ],
    }),
  });

  const json = await r.json();
  const elementId = json.data.checkoutElement.id;
  return NextResponse.json({
    url: `${process.env.FUNGIES_STORE_URL}checkout-element/${elementId}`,
  });
}
```

> **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`:

```ts
import crypto from "node:crypto";
import { NextResponse } from "next/server";

export const runtime = "nodejs";

const seen = new Set<string>(); // swap for a Postgres table in prod

function verify(rawBody: Buffer, sigHeader: string | null, secret: string) {
  if (!sigHeader) return false;
  const expected =
    "sha256_" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(sigHeader);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function POST(req: Request) {
  const raw = Buffer.from(await req.arrayBuffer());
  const sig = req.headers.get("x-fngs-signature");

  if (!verify(raw, sig, process.env.FUNGIES_WEBHOOK_SECRET!)) {
    return new NextResponse("bad signature", { status: 401 });
  }

  const event = JSON.parse(raw.toString("utf8"));

  // Idempotency on event.idempotencyKey, NOT event.id (verified)
  if (seen.has(event.idempotencyKey)) {
    return NextResponse.json({ ok: true, deduped: true });
  }
  seen.add(event.idempotencyKey);

  // Acknowledge fast, do work async — but for the tutorial we do it inline
  await handle(event);
  return NextResponse.json({ ok: true });
}

async function handle(event: any) {
  switch (event.type) {
    case "payment_success":          return onPaymentSuccess(event);
    case "subscription_interval":    return onRenewal(event);
    case "subscription_updated":     return onPlanChange(event);
    case "subscription_cancelled":   return onCancel(event);
    case "payment_refunded":         return onRefund(event);
    case "payment_failed":           return onPaymentFailed(event);
    default:
      console.log("unhandled fungies event", event.type);
  }
}
```

> **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:

```ts
// reads playerId from items[] (set at checkout via Step 3)
function getPlayerId(event: any): string | null {
  const items = event?.data?.items ?? [];
  for (const it of items) {
    const cf = it.customFields ?? {};
    if (cf.playerId) return String(cf.playerId);
  }
  return null;
}

async function onPaymentSuccess(event: any) {
  // type: "subscription_initial" | "subscription_interval" | "one_time"
  const paymentType = event.data.payment?.type;
  const playerId = getPlayerId(event);
  const subscriptionId = event.data.subscription?.id;
  const periodEnd = event.data.subscription?.currentIntervalEnd;

  if (!playerId) {
    console.error("no playerId in custom fields", event.idempotencyKey);
    return;
  }

  if (paymentType === "subscription_initial") {
    await db.subscriptions.upsert({
      playerId,
      fungiesSubscriptionId: subscriptionId,
      status: "active",
      currentPeriodEnd: new Date(periodEnd),
    });
    await grantAccess(playerId);
  }
}

async function onRenewal(event: any) {
  await db.subscriptions.update({
    where: { fungiesSubscriptionId: event.data.subscription.id },
    data: {
      status: "active",
      currentPeriodEnd: new Date(event.data.subscription.currentIntervalEnd),
    },
  });
}

async function onPlanChange(event: any) {
  // e.g. customer upgraded — sync new offer / seats
  await db.subscriptions.update({
    where: { fungiesSubscriptionId: event.data.subscription.id },
    data: { offerId: event.data.subscription.offerId /* ...etc */ },
  });
}

async function onCancel(event: any) {
  // Subscription stays "active" until currentIntervalEnd — revoke then
  await db.subscriptions.update({
    where: { fungiesSubscriptionId: event.data.subscription.id },
    data: {
      status: "cancel_at_period_end",
      cancelAt: new Date(event.data.subscription.currentIntervalEnd),
    },
  });
}

async function onRefund(event: any) {
  const playerId = getPlayerId(event);
  await db.subscriptions.update({
    where: { fungiesSubscriptionId: event.data.subscription?.id },
    data: { status: "refunded" },
  });
  if (playerId) await revokeAccess(playerId);
}

async function onPaymentFailed(event: any) {
  // Subscription will move to past_due — give a grace period before revoke
  await db.subscriptions.update({
    where: { fungiesSubscriptionId: event.data.subscription?.id },
    data: { status: "past_due" },
  });
}
```

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

```
incomplete → active → past_due → unpaid → canceled
                    → canceled
                    → paused → active / canceled
incomplete → incomplete_expired
```

***

### 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:

```bash
curl -X PATCH "$FUNGIES_API_BASE/v0/subscriptions/$SUB_ID/update" \
  -H "x-fngs-public-key: $FUNGIES_PUBLIC_KEY" \
  -H "x-fngs-secret-key: $FUNGIES_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{ "quantity": 5 }'
```

> 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:

```tsx
// .env.local: NEXT_PUBLIC_FUNGIES_STORE_URL=https://yourstore.com/
<a
  href={`${process.env.NEXT_PUBLIC_FUNGIES_STORE_URL}portal`}
  className="text-sm underline hover:no-underline"
  target="_blank"
  rel="noopener noreferrer"
>
  Manage billing
</a>
```

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

```tsx
<a
  href={`${process.env.NEXT_PUBLIC_FUNGIES_STORE_URL}portal?email=${encodeURIComponent(user.email)}`}
  ...
>Manage billing</a>
```

> **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

```bash
# Terminal 1
pnpm dev
# Terminal 2
ngrok http 3000
```

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:

```
   payment_success           subscription_initial
   subscription_created
   
```

4. Trigger a refund from the Fungies dashboard → expect `payment_refunded`.
5. 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  |


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://help.fungies.io/tutorials/saas-developers-with-subscription-products.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
