> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stellartools.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Building Marketplace Apps

> Ship an app that runs natively inside the StellarTools dashboard.

StellarTools Marketplace Apps are web applications embedded directly into the merchant dashboard via an `iframe`. Your app automatically inherits the merchant's active organization context, theme, and time-range filters — and can react to real-time payment events via webhooks.

## Installation

Install the core libraries required to communicate with the host dashboard and use our shared design system.

```bash theme={null}
npm install @stellartools/app-sdk @stellartools/core @stellartools/shared-ui
```

## Styling

Import the shared CSS so your app looks native inside the StellarTools dashboard.

```css theme={null}
/* globals.css */
@import "tailwindcss";
@import "@stellartools/shared-ui/dist/output.css";
```

## Bootstrap your app

### Server-side verification

When StellarTools loads your app it appends `st_token` to your `baseUrl`. This token is a signed JWT prefixed with `st_app_` that contains the `AppContext` — the identity, permissions, and decrypted settings for this installation. Because your App Secret must never reach the browser, verification happens in a server action.

```ts theme={null}
// app/actions/context.ts
"use server";

import { type AppContext } from "@stellartools/app-sdk";
import { APP_TOKEN_PREFIX, STELLARTOOLS_ID, decodeJwt, verifyJwt } from "@stellartools/core";

export async function resolveAppContext(token: string): Promise<AppContext | null> {
  if (!token.startsWith(APP_TOKEN_PREFIX)) return null;

  const rawJwt = token.replace(APP_TOKEN_PREFIX, "");
  const verified = verifyJwt<AppContext>(rawJwt, process.env.YOUR_APP_SECRET!, STELLARTOOLS_ID);

  if (!verified) return null;

  return decodeJwt<AppContext>(rawJwt);
}
```

### Client provider

Reads the token from the URL, calls the server action, and mounts the context for the entire component tree. `StellarAppBootstrap` ensures internal links and fetch calls resolve correctly inside the iframe.

```tsx theme={null}
// app/providers.tsx
"use client";

import * as React from "react";

import { resolveAppContext } from "@/app/actions/context";
import { type AppContext, StellarAppBootstrap, StellarToolsAppProvider } from "@stellartools/app-sdk";
import { useSearchParams } from "next/navigation";

function AppContextBridge({ children }: { children: React.ReactNode }) {
  const searchParams = useSearchParams();
  const token = searchParams.get("st_token");
  const [context, setContext] = React.useState<AppContext | null>(null);

  React.useEffect(() => {
    if (!token) return;
    resolveAppContext(token).then(setContext);
  }, [token]);

  React.useEffect(() => {
    if (!context) return;
    document.documentElement.classList.toggle("dark", context.ui.theme === "dark");
  }, [context]);

  if (!context) return null;

  return (
    <StellarToolsAppProvider context={context}>
      <StellarAppBootstrap baseUrl={process.env.NEXT_PUBLIC_APP_URL!} />
      {children}
    </StellarToolsAppProvider>
  );
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <React.Suspense fallback={null}>
      <AppContextBridge>{children}</AppContextBridge>
    </React.Suspense>
  );
}
```

### Routing on settings

The root page reads `context.settings` and routes the user — no fetch required. Settings are already decrypted inside the token.

```tsx theme={null}
// app/page.tsx
"use client";

import { Suspense, useEffect } from "react";

import { useStellarToolsContext } from "@stellartools/app-sdk";
import { useRouter, useSearchParams } from "next/navigation";

function AppRouter() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const { settings } = useStellarToolsContext();

  useEffect(() => {
    const qs = searchParams.toString() ? `?${searchParams.toString()}` : "";
    router.replace(settings?.connectedApiKey ? `/dashboard${qs}` : `/authentication${qs}`);
  }, [settings, router, searchParams]);

  return null;
}

export default function Page() {
  return (
    <Suspense>
      <AppRouter />
    </Suspense>
  );
}
```

## AppContext reference

`useStellarToolsContext()` returns the full `AppContext` decoded from `st_token`.

| Field           | Type                     | Description                                                               |
| --------------- | ------------------------ | ------------------------------------------------------------------------- |
| `orgId`         | `string`                 | The active organization                                                   |
| `env`           | `"testnet" \| "mainnet"` | Which network the org is on                                               |
| `instId`        | `string`                 | Unique ID for this installation                                           |
| `appId`         | `string`                 | Your app's ID                                                             |
| `scopes`        | `AppScope[]`             | Scopes granted at install time                                            |
| `settings`      | `Record<string, any>`    | Decrypted installation settings — read directly, no fetch needed          |
| `st_token`      | `string`                 | The prefixed `st_app_...` JWT — pass this to server actions and SDK calls |
| `ui.theme`      | `"light" \| "dark"`      | The dashboard's current theme                                             |
| `ui.periodDays` | `number`                 | The selected time window (e.g. `30`)                                      |
| `ui.currency`   | `string`                 | The dashboard's display currency                                          |

## Fetching data

`useStellarToolsQuery` wraps TanStack Query and automatically scopes cache keys to `orgId + env + periodDays`. Switching organizations or time windows triggers a fresh fetch with no extra config.

The fetcher receives the full `AppContext` as its argument — use it to access `settings`, `ui.periodDays`, or anything else from the token.

```tsx theme={null}
"use client";

import { getEmailStats } from "@/app/actions";
import { useStellarToolsQuery } from "@stellartools/app-sdk";

export default function DashboardPage() {
  const { data: stats } = useStellarToolsQuery(
    ["stats"],
    async (context) => getEmailStats(context.settings.apiKey, context.ui.periodDays),
    { enabled: (ctx) => !!ctx.settings.apiKey }
  );

  return <div>{/* render stats */}</div>;
}
```

### Resource hydration

Apps are webhook consumers and webhook event payloads contain only the primary resource. Use the `st_app_` token to hydrate related objects when you need more detail.

```ts theme={null}
import { StellarTools } from "@stellartools/core";

// Retrieve the full customer from a payment event
const st = new StellarTools({ api_key: appToken });
const customer = await st.customers.retrieve(event.data.object.customer_id);
```

## Mutations and saving settings

`useStellarToolsMutation` wraps TanStack Query's `useMutation`, injects the `AppContext` as the second argument, and gives you `isPending` for loading states.

### Server action

```ts theme={null}
// app/actions/index.ts
"use server";

import { StellarTools } from "@stellartools/core";

export const updateSettings = async (
  appToken: string,
  patch: Record<string, string | boolean | undefined>
): Promise<void> => {
  const st = new StellarTools({ api_key: appToken });
  await st.appInstallations.updateSettings(patch);
};
```

The patch is merged into the existing settings object. When you call `updateSettings`, the SDK automatically notifies the host dashboard to refresh its state.

### In your component

```tsx theme={null}
"use client";

import { Suspense, useState } from "react";

import { updateSettings } from "@/app/actions";
import { useStellarToolsContext, useStellarToolsMutation } from "@stellartools/app-sdk";
import { useSearchParams } from "next/navigation";

function SettingsPanel() {
  const { settings } = useStellarToolsContext();
  const appToken = useSearchParams().get("st_token") ?? "";
  const [syncEnabled, setSyncEnabled] = useState(Boolean(settings.customerSyncEnabled));

  const { mutate: toggleSync, isPending } = useStellarToolsMutation(
    async (enabled: boolean) => updateSettings(appToken, { customerSyncEnabled: enabled }),
    { onMutate: (enabled) => setSyncEnabled(enabled) }
  );

  return <Switch checked={syncEnabled} onCheckedChange={toggleSync} disabled={isPending} />;
}

export default function Page() {
  return (
    <Suspense>
      <SettingsPanel />
    </Suspense>
  );
}
```

## First-time setup

On the authentication page, validate the user's credentials before saving them. Return `true` on success, or an error string to display in the form.

```ts theme={null}
// app/actions/index.ts
"use server";

import { StellarTools } from "@stellartools/core";

export const validateAndConnect = async (apiKey: string, appToken: string): Promise<true | string> => {
  const { error } = await validateCredentials(apiKey);
  if (error) return error.message;

  const resolvedConfig = await resolveConfig(apiKey);

  const st = new StellarTools({ api_key: appToken });
  const result = await st.appInstallations.updateSettings({ apiKey, ...resolvedConfig });

  if (result?.error) return result.error;
  return true;
};
```

```tsx theme={null}
// app/authentication/page.tsx
"use client";

import { Suspense } from "react";

import { validateAndConnect } from "@/app/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button, TextAreaField } from "@stellartools/shared-ui";
import { useRouter, useSearchParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";

const schema = z.object({ apiKey: z.string().min(1, "API key is required") });

function AuthenticationForm() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const appToken = searchParams.get("st_token") ?? "";

  const form = useForm({ resolver: zodResolver(schema), defaultValues: { apiKey: "" } });
  const { isSubmitting } = form.formState;

  const onSubmit = async ({ apiKey }: { apiKey: string }) => {
    const result = await validateAndConnect(apiKey, appToken);
    if (result !== true) {
      form.setError("apiKey", { message: result });
      return;
    }
    const qs = searchParams.toString() ? `?${searchParams.toString()}` : "";
    router.push(`/dashboard${qs}`);
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
      <Controller
        control={form.control}
        name="apiKey"
        render={({ field, fieldState }) => (
          <TextAreaField
            {...field}
            id="api-key"
            label="API key"
            placeholder="Enter your API key..."
            error={fieldState.error?.message ?? null}
            rows={3}
            className="resize-none font-mono text-sm shadow-none"
          />
        )}
      />
      <Button type="submit" className="w-full shadow-none" isLoading={isSubmitting}>
        {isSubmitting ? "Connecting…" : "Continue"}
      </Button>
    </form>
  );
}

export default function AuthenticationPage() {
  return (
    <Suspense>
      <AuthenticationForm />
    </Suspense>
  );
}
```

## Webhooks

When a matching event fires, StellarTools POSTs a signed payload to your `webhookUrl`. The body contains both the `event` and the installation's decrypted `settings` — your handler can access merchant config without a database round-trip.

### Verify and handle

```ts theme={null}
// app/api/webhook/route.ts
import { type WebhookEvent, WebhookSigner } from "@stellartools/core";
import { NextRequest, NextResponse } from "next/server";

type Settings = {
  apiKey: string;
  // your app's settings shape
};

const wh = new WebhookSigner();

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get("x-stellartools-signature") ?? "";

  try {
    wh.constructEvent(rawBody, signature, process.env.WEBHOOK_SECRET!);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const { event, settings }: { event: WebhookEvent; settings: Settings } = JSON.parse(rawBody);

  switch (event.type) {
    case "payment.confirmed": {
      await handlePayment(event.data.object, settings);
      break;
    }
    case "customer.created": {
      await handleNewCustomer(event.data.object, settings);
      break;
    }
  }

  return NextResponse.json({ ok: true });
}
```

<Note>
  Always check that the required setting (template ID, feature flag, etc.) is present before acting. Merchants may
  install your app before completing setup.
</Note>

### Available events

For the complete list of event types, payloads, and object shapes see [Webhook event types](/webhooks#event-types).

| Event                    | Fires when                                   | `data.object` type |
| ------------------------ | -------------------------------------------- | ------------------ |
| `payment.confirmed`      | A payment is confirmed on-chain              | `Payment`          |
| `payment.pending`        | A payment is submitted but not yet confirmed | `Payment`          |
| `payment.failed`         | A payment fails on-chain                     | `Payment`          |
| `refund.succeeded`       | A refund is issued successfully              | `Refund`           |
| `refund.failed`          | A refund could not be processed              | `Refund`           |
| `subscription.created`   | A new subscription starts                    | `Subscription`     |
| `subscription.updated`   | A subscription is modified                   | `Subscription`     |
| `subscription.canceled`  | A subscription is canceled                   | `Subscription`     |
| `checkout.created`       | A checkout session opens                     | `Checkout`         |
| `customer.created`       | A new customer is created                    | `Customer`         |
| `customer.updated`       | Customer details change                      | `Customer`         |
| `customer.deleted`       | A customer is deleted                        | `Customer`         |
| `payment_method.created` | A wallet is linked to a customer             | `CustomerWallet`   |
| `payment_method.deleted` | A wallet is removed from a customer          | `CustomerWallet`   |

## The App Manifest

The manifest is the source of truth for your app's identity and requirements. Submit it when publishing to the marketplace.

```json theme={null}
{
  "name": "My App",
  "description": "A short description shown in the marketplace (max 200 chars).",
  "homepageUrl": "https://myapp.com",
  "baseUrl": "https://myapp.com/stellartools",
  "webhookUrl": "https://myapp.com/api/webhooks/stellar",
  "scopes": ["read:customers", "read:payments"],
  "sensitiveKeys": ["apiKey", "secretToken"],
  "version": "1.0.0"
}
```

<ParamField body="name" type="string" required>
  Display name in the marketplace. 2–50 characters.
</ParamField>

<ParamField body="description" type="string" required>
  Short description shown on the listing card. Max 200 characters.
</ParamField>

<ParamField body="homepageUrl" type="string" required>
  Your app's public homepage.
</ParamField>

<ParamField body="baseUrl" type="string" required>
  The URL StellarTools loads inside the iframe. `st_token` is appended as a query param automatically.
</ParamField>

<ParamField body="webhookUrl" type="string">
  If provided, StellarTools POSTs signed event payloads here. Omit if you don't use webhooks.
</ParamField>

<ParamField body="scopes" type="string[]" required>
  Permissions requested at install time. Declare only what your app actually needs.
</ParamField>

<ParamField body="sensitiveKeys" type="string[]">
  Keys in your settings that require encryption at rest. See [Sensitive keys](#sensitive-keys) below.
</ParamField>

### Sensitive keys

Add any settings property that holds a secret (API keys, tokens, credentials) to `sensitiveKeys`. StellarTools will:

* Encrypt the value with a platform-level master key before writing it to the database.
* Decrypt it on-the-fly before delivering it to your app server via the `AppContext` or webhook payload.

Your app logic always receives the plain-text value — the database only ever stores ciphertext.

```json theme={null}
{
  "sensitiveKeys": ["apiKey", "webhookSigningSecret"]
}
```

## Scopes

Declare only the scopes your app strictly needs. Merchants see these permissions during installation.

| Scope                    | What it grants                                                         |
| ------------------------ | ---------------------------------------------------------------------- |
| `read:customers`         | Customer records                                                       |
| `read:payments`          | Payment history and status                                             |
| `read:subscriptions`     | Subscription lifecycle                                                 |
| `read:checkouts`         | Checkout sessions                                                      |
| `read:payouts`           | Payout records                                                         |
| `read:refunds`           | Refund records                                                         |
| `read:payment_methods`   | Saved payment methods                                                  |
| `read:products`          | Product catalog                                                        |
| `read:portal`            | Customer portal sessions                                               |
| `write:app-installation` | Allow the app UI to update its own settings                            |
| `*`                      | Full access — shown prominently to users during install, use sparingly |

## Environment variables

```env theme={null}
YOUR_APP_SECRET=sec_...              # App signing secret — used by resolveAppContext
NEXT_PUBLIC_APP_URL=https://myapp.com
WEBHOOK_SECRET=whsec_...             # For verifying incoming webhook signatures
```

## Deployment

Host your app on any provider (Vercel, AWS, etc.). Before submitting, ensure:

* `baseUrl` is accessible over HTTPS.
* `YOUR_APP_SECRET` is set in your production environment.
* Your server correctly strips the `st_app_` prefix before passing the raw JWT to `verifyJwt`.

## Example apps

These are fully working marketplace apps you can reference or clone.

| App                                                                                          | What it does                                                                            |
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| [Resend](https://github.com/payrouteshq/stellartools/tree/main/apps/marketplace-apps/resend) | Sends transactional emails on payment, subscription, and refund events using Resend.    |
| [Loops](https://github.com/payrouteshq/stellartools/tree/main/apps/marketplace-apps/loops)   | Syncs customers to a Loops audience and triggers email journeys on StellarTools events. |

## Submit your app

App submission is currently in private beta.
To list your app in the StellarTools Marketplace, contact [partners@stellartools.dev](partners@stellartools.dev).
