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:
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)
{
"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:
// 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):
// 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:
// 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 |
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:
// 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:
npm run addons:bundle # scripts/bundle-addons.mjs
npm run addons:catalog # scripts/gen-addon-catalog.mjs
addons:bundlescanssrc/product/addons/*/addon.jsonon disk and regenerates the four aggregator indexes:src/product/addons/index.ts→ADDON_CHANNELSsrc/product/features/index.ts→ADDON_FEATURESsrc/product/adapters-server/index.ts→ADDON_SERVER_ADAPTERSsrc/product/adapters-client/index.ts→ADDON_CLIENT_ADAPTERS
addons:catalogwritesdocs/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:
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:
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:
- Free provider → always allowed.
- License entitlement includes
"*"/"all", the channel id (whole-channel unlock), or the exact"<channel>:<provider>"key → allowed. - A per-add-on purchase code has been activated for this domain → allowed.
- 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 inintegration_connectionswith anis_activeflag (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/licenselists 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— correctid,channel,provider,serverAdapters,premium,vendor. -
index.tsexports a one-providerChannelDef(bluebrand-*tint) and the named driver. -
ProviderDeffields cover every credential; secrets usetype: "secret"; helpfulsetupsteps included. - Driver implements the full channel contract and recomputes money server-side; no secret is ever returned or logged.
-
installation-guide.mdwritten for the operator's staff. -
npm run addons:bundle && npm run addons:catalogre-run;npx tsc --noEmitclean. - Connected and tested end-to-end against the provider's sandbox on localhost.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com