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.
| 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.
"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.
| 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.
{
"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"
}
Display name in the marketplace. 2–50 characters.
Short description shown on the listing card. Max 200 characters.
Your app’s public homepage.
The URL StellarTools loads inside the iframe. st_token is appended as a query param automatically.
If provided, StellarTools POSTs signed event payloads here. Omit if you don’t use webhooks.
Permissions requested at install time. Declare only what your app actually needs.
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.
| 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
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 | Sends transactional emails on payment, subscription, and refund events using Resend. |
| 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.