Skip to main content

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.

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:

Session phase

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.

Fulfillment phase

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.
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:
npm install @citymapia-llc/storetalk-app-sdk
Minimal app:
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():
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.
{
  "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:
fastify.get('/api/manifest', async () => app.manifest());

Fulfillment updates

After a session completes, you can keep sending updates:
// 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.
// 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):
// 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:
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 slugPurpose
paymentCollect online payments — UPI, cards, net banking
meetCreate and cancel video-meeting rooms, return a join URL
For the storeowner’s view of how providers and consumers compose, see the Platform Services guide.

Declaring a contract in your manifest

A provider lists what it offers under serviceContracts:
// apps/storetalk-meet/src/routes/manifest.ts
const app = new StoreTalkApp({
  slug: 'storetalk-meet',
  serviceContracts: ['meet'],
  // ...
});
A consumer lists what it needs under serviceDependencies:
// 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)

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:
app.onServiceResponse('payment', async (event) => {
  // event.requestId, event.deliveryId, event.status, event.data
});

Handling a service request (provider side)

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

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

Apps overview

How apps fit into StoreTalk from a tenant perspective.

Managing apps

What the tenant-facing management experience looks like.