Skip to main content
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.
npm install @stellartools/app-sdk @stellartools/core @stellartools/shared-ui

Styling

Import the shared CSS so your app looks native inside the StellarTools dashboard.
/* 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.
// 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.
// 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.
// 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.
FieldTypeDescription
orgIdstringThe active organization
env"testnet" | "mainnet"Which network the org is on
instIdstringUnique ID for this installation
appIdstringYour app’s ID
scopesAppScope[]Scopes granted at install time
settingsRecord<string, any>Decrypted installation settings — read directly, no fetch needed
st_tokenstringThe prefixed st_app_... JWT — pass this to server actions and SDK calls
ui.theme"light" | "dark"The dashboard’s current theme
ui.periodDaysnumberThe selected time window (e.g. 30)
ui.currencystringThe 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.
"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.
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

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

"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.
// 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;
};
// 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

// 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 });
}
Always check that the required setting (template ID, feature flag, etc.) is present before acting. Merchants may install your app before completing setup.

Available events

For the complete list of event types, payloads, and object shapes see Webhook event types.
EventFires whendata.object type
payment.confirmedA payment is confirmed on-chainPayment
payment.pendingA payment is submitted but not yet confirmedPayment
payment.failedA payment fails on-chainPayment
refund.succeededA refund is issued successfullyRefund
refund.failedA refund could not be processedRefund
subscription.createdA new subscription startsSubscription
subscription.updatedA subscription is modifiedSubscription
subscription.canceledA subscription is canceledSubscription
checkout.createdA checkout session opensCheckout
customer.createdA new customer is createdCustomer
customer.updatedCustomer details changeCustomer
customer.deletedA customer is deletedCustomer
payment_method.createdA wallet is linked to a customerCustomerWallet
payment_method.deletedA wallet is removed from a customerCustomerWallet

The App Manifest

The manifest is the source of truth for your app’s identity and requirements. Submit it when publishing to the marketplace.
{
  "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"
}
name
string
required
Display name in the marketplace. 2–50 characters.
description
string
required
Short description shown on the listing card. Max 200 characters.
homepageUrl
string
required
Your app’s public homepage.
baseUrl
string
required
The URL StellarTools loads inside the iframe. st_token is appended as a query param automatically.
webhookUrl
string
If provided, StellarTools POSTs signed event payloads here. Omit if you don’t use webhooks.
scopes
string[]
required
Permissions requested at install time. Declare only what your app actually needs.
sensitiveKeys
string[]
Keys in your settings that require encryption at rest. See Sensitive keys below.

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.
{
  "sensitiveKeys": ["apiKey", "webhookSigningSecret"]
}

Scopes

Declare only the scopes your app strictly needs. Merchants see these permissions during installation.
ScopeWhat it grants
read:customersCustomer records
read:paymentsPayment history and status
read:subscriptionsSubscription lifecycle
read:checkoutsCheckout sessions
read:payoutsPayout records
read:refundsRefund records
read:payment_methodsSaved payment methods
read:productsProduct catalog
read:portalCustomer portal sessions
write:app-installationAllow the app UI to update its own settings
*Full access — shown prominently to users during install, use sparingly

Environment variables

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.
AppWhat it does
ResendSends transactional emails on payment, subscription, and refund events using Resend.
LoopsSyncs 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.