Add-on Development Guide

Tours ships as a single codebase with dozens of built-in add-ons living under src/product/addons/<id>/. There is no ZIP to upload and nothing to npm install at runtime — every add-on is already in the source tree, and the marketplace decides at request time which ones are unlocked and connected. Add-ons come in two shapes:

  • Channel integrations — connect a third-party provider to do a job for the marketplace: take payments at checkout, send booking emails, store tour photos, inject an analytics script, deliver a video. This guide covers channel add-ons.
  • Feature plugins — add a whole capability to the product: an operator payout ledger, a departures calendar, a support desk, announcements. See the Plugin Development Guide for those.

A channel add-on is gated by license entitlement + an admin toggle: it is usable only when the license covers it (or its purchase code has been activated) and an admin has connected it. This guide walks through building one end-to-end, using Payments — Stripe (src/product/addons/payments-stripe/) as the worked example, and it applies equally to a new payment gateway, email sender, storage backend, or analytics provider.

Concepts: channels, providers, drivers

Three words carry the whole model, so pin them down first.

Term What it is Example
Channel A category of job the marketplace needs done. Channels are the cards an admin sees under Settings → Integrations. Payments, Email, Storage, Analytics, Video
Provider A concrete third-party service that fulfils a channel. One channel holds many providers. Stripe, Razorpay and PayPal all sit under Payments
Driver (adapter) The server-side code that actually talks to the provider using the admin's saved credentials. stripeDriver.create() opens a Checkout Session

An add-on's job is to contribute one provider to one channel, plus the driver that runs it. The core registry merges every add-on's single-provider channel definition into the shared channel cards, so payments-stripe and payments-razorpay both appear under one Payments card.

Anatomy of a channel add-on

A channel add-on is a folder under src/product/addons/<id>/ containing a manifest and the code it references. Stripe's folder:

Code
src/product/addons/payments-stripe/
  addon.json            manifest — id, channel, provider, adapters, gating
  index.ts              exports `channel` (ChannelDef) and `driver`
  stripe.ts             the ProviderDef — credential fields + setup steps
  driver.ts             the PaymentDriver — create() / verify()
  installation-guide.md operator-facing setup docs shown in admin

Keep the folder self-contained: everything the provider needs lives here, and nothing outside imports from it directly. Core reaches the add-on only through the generated aggregator indexes described later.

The manifest (addon.json)

JSON
{
  "id": "payments-stripe",
  "name": "Payments — Stripe",
  "channel": "payments",
  "provider": "stripe",
  "version": "1.0.0",
  "type": "code",
  "premium": true,
  "serverAdapters": { "payment": "driver" },
  "products": ["*"],
  "vendor": "creative-cape.com"
}
Field Meaning
id Unique add-on id — must equal the folder name
name Human label shown in the admin catalog
channel / provider The channel it joins and the provider id it adds
type "code" for a bundled first-party add-on (the only supported type today)
serverAdapters Runtime adapters to wire in; maps an adapter kind (payment) to the export name in the add-on (driver)
clientAdapters (optional) Same, for browser-side adapters (e.g. a script injector or a client SDK bootstrap)
premium true → gated behind a license entitlement or a purchase code
products Which products this add-on supports — "*" for any product built on this core, or a specific id like "tours"
vendor Always creative-cape.com for CreativeCape-authored add-ons

The ChannelDef and ProviderDef

index.ts exports a ChannelDef (the channel "shell" — label, icon, tint) containing exactly one ProviderDef, plus the driver named in serverAdapters. The shared types live in src/core/channels/types.ts:

TS
// index.ts
import type { ChannelDef } from "@/core/channels/types";
import type { PaymentDriver } from "@/core/payments/types";
import { CreditCard } from "lucide-react";
import { stripe } from "./stripe";
import { stripeDriver } from "./driver";

export const channel: ChannelDef = {
  id: "payments",
  label: "Payments",
  desc: "Collect payments at checkout and on invoices.",
  icon: CreditCard,
  tint: "bg-brand-50 text-brand-600", // brand tokens only — never raw hex
  providers: [stripe],
};

export const driver: PaymentDriver = stripeDriver;

The tint (and each provider's color) must use design tokens, not raw colors. The marketplace brand is blue, so channel and provider chips use brand-* for accents and ink-* for neutral surfaces — e.g. bg-brand-50 text-brand-600. Never introduce off-brand hues (violet/indigo/purple/teal) for a channel or provider tint.

The ProviderDef (stripe.ts) declares the credential fields an admin fills in and optional setup steps. Field types are text / secret / select (ChannelFieldType in src/core/channels/types.ts):

TS
// stripe.ts
import type { ProviderDef } from "@/core/channels/types";

export const stripe: ProviderDef = {
  id: "stripe",
  name: "Stripe",
  desc: "Global card processor.",
  color: "bg-brand-50 text-brand-600",
  fields: [
    {
      key: "mode",
      label: "Mode",
      type: "select",
      options: [
        { value: "live", label: "Live" },
        { value: "sandbox", label: "Sandbox / Test" },
      ],
    },
    {
      key: "publishable_key",
      label: "Publishable Key",
      type: "text",
      required: true,
      placeholder: "pk_live_…",
    },
    {
      key: "secret_key",
      label: "Secret Key",
      type: "secret",
      required: true,
      placeholder: "sk_live_…",
    },
    { key: "webhook_secret", label: "Webhook Secret", type: "secret" },
  ],
  setup: [
    { title: "Get API keys", desc: "Stripe Dashboard → Developers → API keys." },
    { title: "Add a webhook secret", desc: "Create a webhook endpoint and copy its signing secret." },
  ],
};

secret fields are encrypted at rest (AES-256-GCM, key from SECRET_KEY) and always masked (••••••••) in API responses. Never log a decrypted secret and never send one to the browser. setup steps render as an inline checklist next to the connect form so an operator's staff can follow along without leaving admin.

The driver / adapter

driver.ts implements the runtime contract for its channel — for payments that is PaymentDriver (src/core/payments/types.ts), with create() (start a payment) and verify() (confirm it). Drivers are self-contained and receive their saved (decrypted) config plus request context:

TS
// driver.ts (abridged)
import type { PaymentDriver } from "@/core/payments/types";

export const stripeDriver: PaymentDriver = {
  provider: "stripe",
  label: "Card (Stripe)",
  kind: "redirect",
  create: async (ctx) => {
    const sk = ctx.config.secret_key;        // the admin's saved, decrypted secret
    const amount = ctx.amount;               // recomputed server-side — never client-supplied
    // …create a Stripe Checkout Session for `amount` in the booking's currency…
    return { kind: "redirect", redirectUrl: url, ref: sessionId };
  },
  verify: async (ctx) => {
    // …confirm the session was paid, then let core mark the booking paid…
    return { paid: true, ref: paymentIntent };
  },
};

A payment driver never trusts a client amount. Core hands the driver the total it recomputed from the booking (departure price × travelers + extras − coupon), matching the marketplace-wide rule that money is derived on the server and travels in minor units (cents).

Other channels expose their own adapter contracts under src/core/<channel>/ and src/core/adapters/:

Channel Adapter kind Core contract Key methods
Payments payment PaymentDriver create(), verify()
Email email EmailSender send({ to, subject, html })
Storage storage StorageDriver put(), url(), delete()
Analytics analytics (client) script injector head() / body() snippets
Video video VideoProvider embed(), thumbnail()

To add, say, a new email provider, you follow the same shape: a ProviderDef for the credential fields and an EmailSender driver under serverAdapters.email.

Registration & merging

Add-ons are wired into core through generated aggregator indexes, never manual edits. The channel registry merges base channels with installed add-on channels:

TS
// src/core/channels/registry.ts
export const CHANNELS: ChannelDef[] = mergeChannels([
  emailChannel,
  paymentsChannel,
  storageChannel,
  analyticsChannel,
  videoChannel,
  ...ADDON_CHANNELS, // one provider per add-on, from src/product/addons/index.ts
]);

Each add-on contributes a one-provider ChannelDef; mergeChannels() combines defs that share an id into a single channel card and de-duplicates providers by id. So payments-stripe, payments-razorpay, and payments-paystack all surface under one Payments card with three providers.

ADDON_CHANNELS, ADDON_FEATURES, and the server/client adapter maps are produced by the bundler — never hand-edited. If you add a file to an add-on but forget to re-bundle, core won't see it.

Build step: bundle & catalog

Run after adding, removing, or changing any add-on:

Terminal
npm run addons:bundle    # scripts/bundle-addons.mjs
npm run addons:catalog   # scripts/gen-addon-catalog.mjs
  • addons:bundle scans src/product/addons/*/addon.json on disk and regenerates the four aggregator indexes:
    • src/product/addons/index.tsADDON_CHANNELS
    • src/product/features/index.tsADDON_FEATURES
    • src/product/adapters-server/index.tsADDON_SERVER_ADAPTERS
    • src/product/adapters-client/index.tsADDON_CLIENT_ADAPTERS
  • addons:catalog writes docs/add-ons/catalog.json (and a README index) — the machine-readable catalog consumed by creative-cape.com, including each add-on's entitlement key.

After bundling, verify the tree still typechecks:

Terminal
npx tsc --noEmit -p tsconfig.json

Licensing & gating

Whether an add-on is premium is set by addon.json.premium and confirmed by the free/premium tier set in src/core/channels/tiers.ts. Only a small base set of providers is free:

Code
email:smtp · analytics:ga4 · payments:cod · payments:bank · payments:paypal
storage:r2 · video:youtube · video:vimeo

Every other provider is premium. At request time premiumBlock(headers, channel, provider) (src/lib/premium.ts) decides access:

  1. Free provider → always allowed.
  2. License entitlement includes "*" / "all", the channel id (whole-channel unlock), or the exact "<channel>:<provider>" key → allowed.
  3. A per-add-on purchase code has been activated for this domain → allowed.
  4. Otherwise → blocked with an unlock message.

On development / localhost hosts everything is unlocked, so you can build and test any provider freely before shipping.

Connecting & purchase codes in admin

Admins manage all of this at Settings → Integrations (/admin/settings/integrations):

  • Connect a provider by filling its fields; credentials are encrypted and the connection is stored in integration_connections with an is_active flag (and one primary connection per channel).
  • Enter a purchase code to unlock a premium provider not covered by the main license. POST /api/v1/admin/addons/license ({ channel, provider, code }) validates the code and records the entitlement for the domain; GET /api/v1/admin/addons/license lists current add-on licenses.

A premium channel add-on is therefore live only when it is entitled (license or purchase code) and an admin has connected and activated it.

Build checklist

Before you consider a new channel add-on done:

  • src/product/addons/<id>/addon.json — correct id, channel, provider, serverAdapters, premium, vendor.
  • index.ts exports a one-provider ChannelDef (blue brand-* tint) and the named driver.
  • ProviderDef fields cover every credential; secrets use type: "secret"; helpful setup steps included.
  • Driver implements the full channel contract and recomputes money server-side; no secret is ever returned or logged.
  • installation-guide.md written for the operator's staff.
  • npm run addons:bundle && npm run addons:catalog re-run; npx tsc --noEmit clean.
  • Connected and tested end-to-end against the provider's sandbox on localhost.

© CreativeCape Solutions · creative-cape.com · support@creative-cape.com