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

# Building Apps — Developer Guide

> Build a StoreTalk App that plugs into WhatsApp flows via the published SDK

This guide is for developers building new **StoreTalk Apps**. An app is a standalone web service that receives signed webhooks from StoreTalk and returns results for flows to act on. You can build one in any language — we publish a TypeScript SDK that handles the protocol for you.

## Architecture overview

```
┌──────────────┐       webhook        ┌──────────────┐
│  StoreTalk   │ ───────────────────→ │   Your App   │
│  Flow Engine │                      │    Server    │
│              │ ←─────────────────── │              │
│              │      callback        │              │
└──────────────┘                      └──────────────┘
        ↕                                    ↕
   WhatsApp API                        Your Web UI
   (sends CTA button                   (customer browses,
    to customer)                        places order, etc.)
```

1. Customer triggers a flow → reaches an **App node**
2. StoreTalk calls your app's webhook with `session.create`
3. Your app returns a **session URL** (your web UI)
4. StoreTalk sends the URL as a WhatsApp button to the customer
5. Customer completes the action in your web UI
6. Your app calls StoreTalk's **callback URL** with `session.complete`
7. Flow resumes with the variables you return

## Two-phase lifecycle

StoreTalk apps run in two distinct phases:

<CardGroup cols={2}>
  <Card title="Session phase" icon="clock" color="#1a7f5a">
    The customer is actively engaged. The flow is **paused** at the App node. Status pushes move the session forward. A terminal status (like `order_confirmed`) completes the session and resumes the flow.
  </Card>

  <Card title="Fulfillment phase" icon="truck" color="#1a7f5a">
    The flow has already finished. Your app continues working in the background (packing an order, tracking delivery). Each status push sends a WhatsApp message directly to the customer — no flow involvement.
  </Card>
</CardGroup>

Example lifecycle for an e-commerce app:

```
SESSION phase       : browsing → cart_updated → order_confirmed (terminal ✓)
                                                      ↓
                                              [flow resumes here]
FULFILLMENT phase   : order_accepted → picking → preparing → shipped → delivered
```

Customer is freed after `order_confirmed`. All the delivery updates happen in fulfillment without the flow needing to wait.

## Start with the SDK

Install the official SDK:

```bash theme={null}
npm install @citymapia-llc/storetalk-app-sdk
```

Minimal app:

```typescript theme={null}
import { StoreTalkApp } from '@citymapia-llc/storetalk-app-sdk';
import Fastify from 'fastify';

export const app = new StoreTalkApp({
  slug: 'my-app',
  secret: process.env.STORETALK_APP_SECRET!,
  lifecycle: [
    { status: 'started',  phase: 'session', terminal: false },
    { status: 'completed', phase: 'session', terminal: true,
      variables: ['result'] },
  ],
});

// Handle incoming session.create
app.onSessionCreate(async (event) => {
  // Return a URL StoreTalk will send as a WhatsApp CTA button
  return {
    status: 'success',
    session: {
      url: `https://my-app.example.com/s/${event.delivery_id}`,
      expires_in_seconds: 1800,
    },
    message: {
      body: 'Tap to get started',
      button_text: 'Open',
    },
  };
});

// Your HTTP server
const fastify = Fastify();
fastify.post('/webhook', async (req, reply) => {
  return app.handleWebhook(req.raw, reply.raw);
});
fastify.listen({ port: 3000 });
```

When the customer finishes, call `app.complete()`:

```typescript theme={null}
await app.complete(
  { deliveryId, callbackUrl },
  'completed',
  { result: 'Customer entered: Hello!' }
);
```

The flow resumes with `{{result}}` available as a variable.

## The manifest

Every app must expose `GET /api/manifest`. StoreTalk reads it when registering the app to learn what actions and events it supports.

```json theme={null}
{
  "slug": "my-app",
  "actions": [
    {
      "slug": "open_form",
      "label": "Open Form",
      "description": "Show a feedback form to the customer"
    }
  ],
  "lifecycle": [...],
  "sdk_version": "0.6.0",
  "webhook_url": "https://my-app.example.com/webhook",
  "public_url": "https://my-app.example.com"
}
```

The SDK auto-generates this from your `StoreTalkApp` config. Just mount it:

```typescript theme={null}
fastify.get('/api/manifest', async () => app.manifest());
```

## Fulfillment updates

After a session completes, you can keep sending updates:

```typescript theme={null}
// Non-terminal — keeps the lifecycle open
await app.pushFulfillment(session, 'shipped', {
  tracking_number: 'AWB123456',
});

// Terminal — closes the fulfillment lifecycle
await app.completeFulfillment(session, 'delivered', {
  delivery_time: '3:45 PM',
});
```

Each push triggers a WhatsApp message to the customer using the template the store owner has configured for that status.

## URL shortener

When your app sends a customer-facing URL via WhatsApp — a booking link, a meeting join link, an order tracking page — the URL usually carries a JWT or signed access token in the query string and balloons to \~300 characters. WhatsApp doesn't auto-shorten, so the customer's message renders as a wall of URL with the actual content (provider name, time, location) buried below.

The platform shortener (`s.storetalk.app`) collapses these to \~28 characters. Apps call it via the SDK; the response is a redirect URL ready to embed in a message.

```typescript theme={null}
// Shorten a URL with an explicit expiry
const result = await app.shortenUrl('https://shop.storetalk.app/orders/abc?token=eyJhbG...', {
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
  refId: `order:${orderId}`,                 // for bulk revoke later
  tenantId: install.tenantId,
});
// → { code: "Xy7q2kF", shortUrl: "https://s.storetalk.app/Xy7q2kF", expiresAt: "..." }

// Or, if your URL carries a JWT, let the SDK extract the `exp` claim:
const result = await app.shortenJwtUrl(longUrl, jwt, {
  refId: `order:${orderId}`,
  tenantId: install.tenantId,
});
```

### `expiresAt` is required

There is no default. Caller must scope `expiresAt` to the underlying token or entity validity:

* **Booking flows:** `endTime + 24h grace`
* **Order tracking:** delivery deadline + a few days
* **Cart recovery:** the cart's TTL
* **JWT-bearing URLs:** prefer `shortenJwtUrl()` — extracts the `exp` claim automatically (no signature verification; that happens at the destination)

A blanket 30-day default would invite stale short links pointing to long-expired tokens. Treat short URLs as exactly as sensitive as the long URLs they wrap — anyone who holds the short URL can resolve it.

### Revoking on cancellation

Tag every URL minted for a logical entity with the same `refId`, then revoke them all in one call when the entity reaches a terminal state (booking cancelled, order refunded, cart abandoned past TTL):

```typescript theme={null}
// Revoke every short URL this app minted with refId "order:123"
await app.revokeShortUrlsByRef(`order:${orderId}`);

// Or revoke a single code if you tracked it explicitly
await app.revokeShortUrl(code);
```

After revoke, customer taps on the short URL render the branded "this link has been cancelled" landing page (English + Hindi) for 30 days, then fall through to "no longer active". Idempotent — a second revoke returns `revoked: 0`.

### Best-effort fallback

If `s.storetalk.app` is unreachable, your app should log and fall back to the long URL rather than block the WhatsApp message. The customer still gets a working link, just unbranded:

```typescript theme={null}
async function shortenOrFallback(url: string, opts: ShortenUrlOptions): Promise<string> {
  try {
    const result = await app.shortenUrl(url, opts);
    return result.shortUrl;
  } catch (err) {
    console.warn('[shortener] failed, using long URL', err);
    return url;
  }
}
```

### Where to wire it in

Audit every code path where your app builds a customer-facing URL that goes into a WhatsApp message — typically:

* **Confirmation messages** (`app.complete()` after a booking/order is finalised)
* **Reminders / scheduled notifications** (`app.scheduleNotify()` — shorten *before* the message body is rendered, since the rendered text is stored in the scheduler payload)
* **Lifecycle pushes** (`app.pushFulfillment()`, `app.completeFulfillment()`)
* **Direct notifications** (`app.notify()`)

Centralise in a single helper file (e.g. `src/lib/shortener.ts`) so all paths agree on the `refId` convention and `expiresAt` policy. The booking-pro app's `lib/booking-shortener.ts` is the canonical example.

## Service contracts

Some capabilities are too universal to bake into every app — taking a payment, hosting a video meeting, listing on a marketplace. StoreTalk exposes them as **service contracts**. Your app declares either that it provides one (you're a payment gateway, a video host, etc.) or that it consumes one (you need payments, you need meetings) — and Core wires the two sides together at runtime.

Two contracts ship today:

| Contract slug | Purpose                                                  |
| ------------- | -------------------------------------------------------- |
| `payment`     | Collect online payments — UPI, cards, net banking        |
| `meet`        | Create and cancel video-meeting rooms, return a join URL |

For the storeowner's view of how providers and consumers compose, see the [Platform Services](/apps/platform-services) guide.

### Declaring a contract in your manifest

A **provider** lists what it offers under `serviceContracts`:

```typescript theme={null}
// apps/storetalk-meet/src/routes/manifest.ts
const app = new StoreTalkApp({
  slug: 'storetalk-meet',
  serviceContracts: ['meet'],
  // ...
});
```

A **consumer** lists what it needs under `serviceDependencies`:

```typescript theme={null}
// apps/booking-pro/src/routes/manifest.ts
const app = new StoreTalkApp({
  slug: 'booking-pro',
  serviceDependencies: ['payment', 'meet'],
  // ...
});
```

Both fields appear in the manifest as plain string arrays. The portal reads them to render the **Services Provided** and **Services Used** sections of the Settings tab.

### Calling a service (consumer side)

```typescript theme={null}
const result = await app.requestService('payment', {
  installId: install.id,
  amount: 50000,        // in paise
  currency: 'INR',
  description: 'Booking #4821',
});

// result.data is whatever the provider returned synchronously
const { orderId, key } = result.data;
```

`requestService(contractSlug, payload)` is a single HMAC-signed call to Core. Core resolves the provider (see below), forwards a `service.request` webhook to that provider, waits for its synchronous response (10s timeout), and returns the body to you as `result.data`.

For long-running operations, the provider may also send a follow-up `service.response` webhook. Subscribe to it from the consumer side:

```typescript theme={null}
app.onServiceResponse('payment', async (event) => {
  // event.requestId, event.deliveryId, event.status, event.data
});
```

### Handling a service request (provider side)

```typescript theme={null}
app.registerServiceHandler('meet', async (event) => {
  // event.payload was sent by the consumer
  const room = await createRoom(event.payload);
  return { data: { joinUrl: room.url, roomId: room.id } };
});
```

What you `return` becomes the consumer's `result.data`. To respond asynchronously after some background work, store the `event.delivery_id` and call `respondService(deliveryId, status, data)` later.

### How Core picks a provider

When more than one app provides the same contract on a tenant (e.g. both StoreTalk Meet and StoreTalk Zoom are installed):

1. The tenant's explicit default for that contract wins, if set
2. Otherwise, if exactly one provider is installed, it's used automatically
3. Otherwise the request fails with a clear error

The default is **per-tenant, per-contract** — set in the portal's Services Provided section, stored in `TenantServiceDefault`. Consumer apps don't pick a provider; Core does.

### Graceful degradation

If no provider is installed or all are disabled, `requestService()` rejects with a descriptive `InvalidOperationException`. **Catch it and degrade gracefully** rather than failing the whole flow — the user-facing docs promise this, and well-built apps deliver it:

```typescript theme={null}
try {
  await app.requestService('payment', { ... });
  return 'payment_collected';
} catch (err) {
  // No payment provider configured — proceed as a free booking
  return 'free_booking';
}
```

BookingPro, for example, completes bookings as free when `payment` is unavailable, and skips meeting-link generation when `meet` is unavailable.

### Deploy ordering — non-negotiable when extending a contract

Core does **not** enforce a deploy order at install time, but versioning a contract is fragile. **When you add a new action, field, or behaviour to an existing contract, ship the provider first, then the consumer.**

A real example from PR #99 (booking-pro + StoreTalk Meet, 2026-04-28): we extended the `meet` contract with `action: 'cancel'`. The provider apps (StoreTalk Meet, StoreTalk Zoom) shipped first to start accepting the new action. Only then did booking-pro start emitting it. Had it shipped the other way, the consumer's `cancel` request would have hit an old provider that didn't recognise the action — and a Zoom handler that defaults to "create on unknown action" would have spawned a junk room every time a customer cancelled.

Apply the same rule for any breaking shape change to the request or response payload. New optional fields are safe in either direction; new required fields and new action verbs are not.

## Security

Every webhook from StoreTalk is signed with HMAC-SHA256. The SDK verifies signatures automatically. For manual verification:

```
X-StoreTalk-Signature: sha256=<hex>
X-StoreTalk-Timestamp: <unix_seconds>
X-StoreTalk-Event: <event_type>
```

Outbound callbacks from your app to StoreTalk must also be signed:

```
X-App-Signature: sha256=<hex>
X-App-Id: <your_app_slug>
```

The SDK handles both directions. Signature computation is:

```
HMAC_SHA256(APP_SECRET, rawRequestBody) → hex lowercase
```

## Multi-tenancy

A single app instance serves **all** tenants that install it. Every webhook carries `tenant_id` and `install_id` fields. Your app is responsible for isolating data by `install_id`.

Use `install_id` as the tenant key throughout your database. Never trust cross-tenant data.

## Debug inspector

The SDK ships with a built-in debug UI. Set the `DEBUG_TOKEN` env var (or let StoreTalk sync one via admin handshake) and mount it:

```typescript theme={null}
app.mountDebug(fastify, '/debug');
```

The admin can launch it from the StoreTalk admin portal with one click — no static token needed in production.

## Full contract reference

This guide covers the basics. For the complete contract — every event, every header, every error response — see:

* **[SDK package on GitHub Packages](https://github.com/Citymapia-llc/Gm.WhatsAppManager/tree/master/sdk)** — TypeScript source and type definitions
* **[App webhook contract spec](https://github.com/Citymapia-llc/Gm.WhatsAppManager/blob/master/docs/app-webhook-contract.md)** — canonical protocol reference
* **[App lifecycle pipeline spec](https://github.com/Citymapia-llc/Gm.WhatsAppManager/blob/master/docs/spec-app-lifecycle-pipeline.md)** — why the two-phase model, billing implications, variable declaration

## Getting listed

Once your app is working:

1. Deploy your webhook endpoint at a stable public URL with HTTPS
2. Ensure `/api/manifest` returns correctly
3. Contact the StoreTalk team with your manifest URL to begin the review process
4. Approved apps appear in the tenant marketplace

<CardGroup cols={2}>
  <Card title="Apps overview" icon="puzzle-piece" href="/apps/apps-intro">
    How apps fit into StoreTalk from a tenant perspective.
  </Card>

  <Card title="Managing apps" icon="gear" href="/apps/manage-apps">
    What the tenant-facing management experience looks like.
  </Card>
</CardGroup>
