PingStack apps & bots

Extend a PingStack Server with apps, bots, and webhooks — post messages, ship interactive cards (buttons, selects, modals, live status/progress), react to events, and bridge external systems, all under explicit, revocable permissions. Jump to practical recipes: Examples (website chat, SMS, MCP).

Note  In the product UI a tenant is called a Server. Everywhere in the API and data model it stays tenant. Same thing.

A PingStack app is a first-class actor. It authenticates with a per-webhook signing secret, posts as its own bot identity, and can do exactly what it was granted — nothing more. There is no ambient authority: every capability is checked, from a fresh read, on every call.

Core concepts

ConceptWhat it is
AppA registered extension (appId). Has a name, requested scopes, and a category (native, curated, or custom).
InstallationBinds an app to one execution context — a Server (TENANT) or a user's Home. Carries the granted scopes.
WebhookAn inbound endpoint bound to a channel. POST to it and the app posts a message there. Has a signing secret and optional outbound deliveryUrl + event subscriptions.
ScopeA capability the app is granted (e.g. messages.write). Enforced server-side; capped by the Server's policy ceiling.
ContainerA channel, group, or DM. A webhook is bound to exactly one.

App types

  • Incoming webhook — post-only. No OAuth, no approval; copy a URL and a secret and you're live in minutes.
  • Two-way integration — incoming webhook plus outbound event subscriptions (your service is notified of new messages).
  • Interactive bot — posts App Interactions cards (buttons, selects, modals, live status/progress) and receives signed callbacks on your deliveryUrl.

Quickstart

The fastest path — time to first message is under five minutes:

  1. Open your Server → App Store → “+ Add Custom App” (Server Admin/Owner only).
  2. Name it, pick permissions, choose how it interacts (post to a channel, create its own channel, or be invitable), and finish.
  3. Copy the Webhook URL and signing secret shown on the final screen (the secret is shown once).
  4. POST a signed JSON body to that URL — it appears as a message from your bot.
curl — post your first message
BODY='{"body":"Deploy #4213 succeeded ✅"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')

curl -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: sha256=$SIG" \
  -d "$BODY"

Create a custom app (UI)

The “+ Add Custom App” wizard (App Store → Browse or Installed) walks through provisioning. Pick a kind first:

KindUse when
BotInteractive cards, slash commands, event subscriptions — see Interactive components.
Incoming webhookOne-way post into a channel (CI, alerts) — see Quickstart.
Conversation bridgeTwo-way relay with an external party (SMS, website chat, email, MCP sessions) — see App Conversations and the Examples.

For a conversation bridge the wizard then asks:

StepWhat you set
DetailsName + description.
ProviderA hosted provider (e.g. Twilio for SMS) or My own endpoint (you host ingress + choose delivery_url / poll_queue).
Channel kindLocked by the provider, or chosen for “My own endpoint” (sms, web, …).
Idle timeoutMinutes of inactivity before the bridge session ends (default 30). Writes conversations.session.autoEndMinutesone value for both ends (PingStack lazy auto-end and your BYO visitor idle close). See Website chat.
PermissionsConversation scopes are auto-granted; add more only if needed (see scopes).
BindingHome channel for per-party discussions (pick existing or create, e.g. www-chat).

On finish you get the webhook URL + signing secret (secret shown once). Manage the app afterward from Installed Apps → Configure: edit permissions, rotate the secret, view delivery logs, pause, suspend, or uninstall — all in one place.

Permissions (scopes)

Grant the minimum an app needs. Grants are the app's requested scopes intersected with the Server's policy ceiling, and enforced on every operation.

ScopeAllows
webhooks.incomingReceive inbound webhooks (post messages in).
messages.writePost messages.
messages.readRead message content delivered via subscriptions.
messages.historyRead prior message history (separately gated).
ui.componentsPost interactive component cards (buttons, selects, status/progress).
ui.modalsAttach modal dialogs to buttons (collect structured input).
ui.slash_commandsRegister slash commands in the app manifest.
reactions.writeAdd reactions.
members.readRead the member list.
webhooks.outgoingReceive outbound event subscriptions.

Post a message

POST JSON to your webhook URL. The message is authored as your bot in the bound channel.

FieldTypeNotes
body / textstringPlain-text body.
richBody / markdownstringMarkdown body (≤ 4000 chars).
usernamestringOverride the display name for this post.
avatarUrlstringOverride the avatar for this post.
componentsarrayInteractive component rows (needs ui.components).

Interactive components

Send a components array on create/update; PingStack stores a versioned components card on the message. v1 is buttons-only. v2 adds live activity (status/progress), inline selects, and button-opened modals. Cards with any of those must use version: 2 (app-kit sets this for you).

Clients  Web renders the full v2 surface. Clients that don't recognize a card version or component type show the message body plus a muted Unsupported interactive content chip — never a partial card, never a crash. Interactive components are Server (Tenant) scope only.

Button gallery

FieldNotes
componentIdUnique per message; ^[A-Za-z0-9_.:-]{1,64}$.
labelPlain text, ≤ 40 chars.
actionId xor url xor modalCallback click, link, or open a modal. Exactly one.
style xor colordefault·primary·success·warning·danger·ghost, or a named palette color.
valueOpaque, ≤ 1024 bytes; echoed back on click / select.
confirmOptional confirmation prompt before the callback fires.
requiredPermissioncontainer.manage / members.manage — who may interact (server-enforced).

Limits  2 rows per message · 5 buttons per row · 1 select/status/progress per row · 4000-char markdown · 16 KB serialized card. Rows are homogeneous (don't mix buttons with a select in the same row).

Node — post buttons with @pingstack/app-kit
import { messageWithComponents, button, row, card, signWebhookRequest } from '@pingstack/app-kit';

const payload = messageWithComponents({
  body: 'Approve deploy #4213?',
  components: card({
    rows: [row(
      button({ componentId: 'approve', label: 'Approve', style: 'success', actionId: 'deploy.approve', value: '4213' }),
      button({ componentId: 'reject',  label: 'Reject',  style: 'danger',  actionId: 'deploy.reject',  value: '4213' }),
    )],
  }).components,
});

const raw = JSON.stringify(payload);
await fetch(WEBHOOK_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': signWebhookRequest(raw, SIGNING_SECRET) },
  body: raw,
});

Live activity (status & progress)

For long-running work, add display-only status and progress blocks and patch them in place with op: "update". No callback fires — they are not clickable.

app-kit — queued → working
import { updateMessage, status, progress, button, row, signWebhookRequest } from '@pingstack/app-kit';

const raw = JSON.stringify(updateMessage({
  messageId, seq,
  components: [
    row(status({ title: 'Queued for apply', tone: 'info', lines: ['Waiting for runner'] })),
    row(progress({ label: 'Applying fix', indeterminate: true })),
    row(button({
      componentId: 'done', actionId: 'noop', label: 'Working…',
      style: 'primary', disabled: true,
    })),
  ],
}));
// POST raw to WEBHOOK_URL with X-Webhook-Signature.

Selects & modals

A select is an inline dropdown (its own row). Changing the selection delivers a select interaction with selectedValues. A modal is declared on a button — deliveries are async, so the client opens it locally; submit delivers modal_submit with the field values. Grant ui.modals for modal buttons.

app-kit — select + modal button
import {
  messageWithComponents, button, row, select, modal, textField, selectField, signWebhookRequest,
} from '@pingstack/app-kit';

const payload = messageWithComponents({
  body: 'Pick an environment, or send feedback.',
  components: [
    row(select({
      componentId: 'env', actionId: 'pick_env', placeholder: 'Environment',
      options: [
        { label: 'Dev', value: 'dev' },
        { label: 'Prod', value: 'prod', description: 'careful' },
      ],
    })),
    row(button({
      componentId: 'open_fb', label: 'Send feedback',
      modal: modal({
        modalId: 'feedback', title: 'Send feedback', actionId: 'submit_feedback',
        fields: [
          textField({ fieldId: 'summary', label: 'Summary', required: true, maxLength: 200 }),
          selectField({
            fieldId: 'sev', label: 'Severity',
            options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }],
            initialValue: 'low',
          }),
        ],
      }),
    })),
  ],
});

Update a message

Patch a message your webhook authored (e.g. to disable buttons after a click) by POSTing an op: "update" payload with the messageId and seq. Authorship is enforced — you can only edit your own posts.

app-kit — close the loop
import { updateMessage } from '@pingstack/app-kit';

const raw = JSON.stringify(updateMessage({
  messageId, seq,
  body: 'Deploy #4213 approved ✅ by @dana',
}));
// POST raw to WEBHOOK_URL with the X-Webhook-Signature header.

Interaction callbacks

When a user interacts with your card, PingStack POSTs a signed interaction delivery to your app's deliveryUrl. The actor is minimized to userId + displayName. Verify the signature, then close the loop with an op: "update" on the original message.

typeWhenKey fields
button_clickButton with actionIdcomponent.actionId, component.value
selectInline select changedcomponent.actionId, selectedValues
modal_submitModal submittedmodal.actionId, modal.fields
commandSlash command invokedcommand.name, command.text / options
Node — receive & verify
import { verifyPingStackRequest, parseDelivery, isInteraction } from '@pingstack/app-kit';

app.post('/pingstack', async (req, res) => {
  const raw = req.rawBody; // the exact bytes, not re-serialized
  if (!verifyPingStackRequest(req.headers, raw, SIGNING_SECRET)) return res.sendStatus(401);

  const delivery = parseDelivery(raw);
  if (isInteraction(delivery)) {
    switch (delivery.type) {
      case 'button_click': /* delivery.component.actionId / .value */ break;
      case 'select':       /* delivery.selectedValues */ break;
      case 'modal_submit': /* delivery.modal.fields */ break;
      case 'command':      /* delivery.command.name / .text */ break;
    }
    // ... do work, then POST an op:"update" back to the webhook URL.
  }
  res.sendStatus(200);
});

Deliveries are at-least-once — dedupe on interactionId (or X-PingStack-Delivery).

Slash commands

Slash commands are not card components — declare them in the app manifest (ui.slashCommands, scope ui.slash_commands). Invoking /name … in a Server channel delivers a command interaction to your deliveryUrl (or the command's handlerUrl). The invoke itself does not post a visible message; reply by posting through your inbound webhook.

Event subscriptions

Subscribe a webhook to container events (v1 allowlist: MESSAGE_CREATED, MESSAGE_UPDATED). PingStack delivers them signed to your deliveryUrl. Message bodies are included only if the app holds messages.read; you never receive your own posts (echo-suppressed).

App Conversations

Turn a message from an external party — an SMS sender, a web-chat visitor, an email correspondent — into a conversation inside a Server that members can read and reply to, with the reply routed back out through your app.

App Conversations is one declarative, reusable capability any app can adopt: one inbound relay, one bridge-session primitive, one reply surface, one signed outbound callback. Twilio SMS and the Web Agent are instances of it, not forks. You declare a conversations block in your manifest and the platform gives you the discussion, the reply UI, echo-suppression, and signed delivery for free.

Not the components card  App Conversations is a different feature from interactive components (buttons). Buttons are ui.components attached to one message; a conversation is a two-way bridge to an external party. Never call this feature “message.components” — that name belongs to buttons and is forbidden here.

v1  Conversations are Server (TENANT) scope only. A conversation carries an external party's identity to a third party, so it is rejected on Home-scope webhooks — same posture as interaction deliveries.

The pattern in one picture

inbound relay → discussion → signed outbound reply
  External party                 provider ingress (SMS bridge / Web Agent / yours)
  sms:+1555…                       ├─ authenticate the caller (provider scheme or platform HMAC)
  web:vis_01H…      ──── POST ───▶  ├─ resolve / START a BRIDGE SESSION  (externalId → discussion)
  email:jo@co                      ├─ author the message  senderUserId = {prefix}:{id}
                                   └─ write BRIDGE_META on the container
                                          │
                                          ▼  (lands as a normal message in the discussion)
      Server member reads it — in the discussion, or via the AppConversation indicator + slide-out
                                          │
                                          │  member replies (normal message OR slide-out composer)
                                          ▼
  External party   ◀──── relay ───  MESSAGE_CREATED → ps_outbox → app-event-dispatcher
                                        ├─ echo-suppress ({prefix}: senders)
                                        ├─ recover BRIDGE_META → app deliveryUrl / provider transport
                                        └─ SIGNED delivery (X-PingStack-Signature) → app relays out

The inbound side is provider-specific by necessity (each provider POSTs its own shape and signs its own way). The middle (bridge session + discussion + reply UI) and the outbound signing + dispatch are the platform primitives you inherit.

Vocabulary

TermWhat it is
BridgeAn installed app configured to relay a class of external conversation — realized as an inbound webhook. One install can run N bridges (one per binding).
External partyThe person/system on the far side — a phone number, a web visitor, an email address.
External id{prefix}:{id} — the stable, provider-namespaced identity of the external party (sms:+15551234567, web:vis_01HDEF). The prefix is the bridge's channel kind; the id is opaque to the platform.
Bridge sessionThe mapping external id → discussion container. It is the conversation. Has a lifecycle (active/ended) with lazy inactivity auto-end.
Bridge metaThe reverse pointer on the container that lets the outbound path recover the bridge/app/external-id from a reply — no scan.
Reply surfaceThe in-app UI for reading and replying: the discussion itself, plus the AppConversation indicator + slide-out.

The conversations manifest block

A bridge is declared by a conversations block in your App Manifest. It adds no new scope strings — it composes the existing scope vocabulary and interactionModel, so an app already using webhooks needs no new grant primitives.

manifest — a conversations block
"conversations": {
  "enabled": true,
  "channelKind": "sms",                 // the external-id prefix this bridge owns
  "externalIdLabel": "Phone number",    // human label for the external party (UI)
  "session": {
    "autoEndMinutes": 30,               // ONE idle window for PingStack + BYO visitor bridge (default 30)
    "createsContainer": true            // a new external party ⇒ a discussion the app owns
  },
  "inbound":  { "signature": { /* reuse the manifest signature block */ } },
  "outbound": {
    "transport": "delivery_url",        // delivery_url | provider_rest | poll_queue
    "deliveryUrlParam": "deliveryUrl",  // when transport = delivery_url (signed)
    "egressDomains": []                 // when transport = provider_rest (must ⊆ egressPolicy)
  }
}

Required companion declarations

The conversations block is rejected unless these hold:

  • scopes must include webhooks.incoming and messages.write; a session that creates containers also needs container.manage; a signed delivery_url outbound also needs webhooks.outgoing.
  • interactionModel must be CREATE_CONTAINER when session.createsContainer: true. A single fixed-thread bridge uses POST_TO_EXISTING_CONTAINER with createsContainer: false.
  • Every outbound host — in egressDomains (for provider_rest) or resolved from deliveryUrlParam (for delivery_url) — must be in egressPolicy.allowedDomains. Undeclared egress is prohibited.

channelKind — the external-id prefix registry

channelKind is the prefix in every external id ({prefix}:{id}) and every author senderUserId the bridge creates. It is a reserved, registered token so echo-suppression and sender rendering stay unambiguous platform-wide. Pattern: ^[a-z][a-z0-9]{1,15}$.

channelKindExternal id exampleStatus
smssms:+15551234567live (Twilio)
webweb:vis_01HDEFlive (Web Agent)
emailemail:jane@co.comreserved
webhookforbidden as a channel kind — it is the plain inbound-webhook bot identity, already echo-suppressed.

A manifest requesting an unregistered channelKind is rejected. Registering a new one is a one-line addition to this registry plus the echo-suppression prefix set — no projection handler and no web changes if you use delivery_url.

External identity — {prefix}:{id}

The external id is the join key across the whole feature. It appears, unchanged, in four places:

  1. The bridge session sort key — SESSION#{externalId}.
  2. The authored message's senderUserId{prefix}:{id}.
  3. Echo suppression — outbound drops any message whose senderUserId starts with a registered channel-kind prefix.
  4. The outbound payload's conversation.externalId — your app strips its own prefix to recover the raw address.

Not a user  {prefix}:{id} is a synthetic sender, never a ps_users identity. It holds no membership, no roles, no ambient authority — it exists only to author bridge messages and to be echo-suppressed. The id is opaque: no platform code parses past the first :; render the party with externalIdLabel, never a parse of the id.

Inbound relay — external → PingStack

Your provider ingress Lambda runs these steps, in order (all binding):

  1. Authenticate the caller using conversations.inbound.signature — a provider scheme (e.g. twilio-hmac-sha1) or the platform per-webhook HMAC. Fail-closed on mismatch.
  2. Check install + webhook state from a fresh read — a suspended install or paused webhook is rejected.
  3. Resolve the conversation: compute externalId = {channelKind}:{normalizedId} and call getActiveBridgeSession(webhookId, externalId).
    • Hit → route the message into that discussionId and bump activity.
    • Miss → if createsContainer, post a “new conversation” system message into the bound parent, create the discussion, open the session, and write BRIDGE_META. Otherwise post into the fixed bound container.
  4. Author the message as the external party: senderUserId = {prefix}:{id}, isWebhookMessage: true, with a sender snapshot (displayName = externalLabel, avatar = the app icon). Attachments (e.g. MMS media) map to the normal attachments array.
  5. Write the message + outbox event transactionally via the shared message builder — body passed as plaintext and encrypted by the builder (envelope invariant), never patched on afterward.

The authored message is a normal ps_messages row. That is deliberate: it inherits realtime delivery, unread math, search indexing, and the reply surface for free.

Hosted inbound — no provider Lambda

Simplest  You don't have to host an ingress. POST a signed JSON body to your webhook URL (the same one plain bots use) with an externalId field. Because the webhook declares conversations, PingStack does steps 3–5 for you: it authors the message as {channelKind}:{externalId} and routes it to that party's active session (or the bound channel if none). Your side is just a signed HTTP POST.

FieldNotes
externalId (or from)The external party's raw id — a phone number, visitor id, email. Prefixed with the webhook's channelKind to form {prefix}:{id}. Stable per party.
externalLabelOptional display name for the party (falls back to externalId).
body / richBodyThe inbound message text.
curl — deliver an inbound conversation message
BODY='{"externalId":"+15551234567","externalLabel":"+1 (555) 123-4567","body":"Hi, I need help with my order"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')

curl -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: sha256=$SIG" \
  -d "$BODY"

Omit externalId and the same webhook posts a plain bot message (webhook:{id}) instead — one endpoint serves both.

Session directives on the inbound webhook

For app-owned kinds (notably mcp) — and whenever you need an explicit discussion rooted on a prior message — include a session block on the same signed POST. Requires config.bridgeSession.enabled on the webhook (what the Conversation bridge wizard writes). See also channelKind and MCP connectivity.

inbound POST — open / write an app-owned session
{ "body": "Diagnosing Sentry issue 4213…",
  "components": [ /* optional cards — needs ui.components */ ],
  "session": {
    "externalId": "w-ms20ymhp-0",   // bare id, or "mcp:w-ms20ymhp-0"
    "label": "Ship the thing",      // discussion title on create
    "parentSeq": 41,                // seq of the ROOT message in the bound channel
    "ensure": true,                 // create if missing (default when parentSeq is set)
    "end": false                    // true → close after this message (app-owned only path)
  } }
  • A bare externalId is prefixed with the webhook’s externalIdPrefix (e.g. mcp:).
  • Resolution is idempotent on (webhookId, externalId).
  • Response includes session: { externalId, discussionId, created, ended? }.
  • Members may end sms / web sessions; only the app may end mcp (session.end: true).

The reply thread & reply surface

A member reads and replies to a conversation two ways, and both converge on the same signed outbound dispatch:

  • In-discussion reply — the member types a normal message in the conversation's discussion.
  • Slide-out reply — the member uses the AppConversation slide-out composer.

The reply UI is a shared web-shell surface — your app supplies only labels, never bespoke components:

SurfaceWhat it does
AppConversationIndicatorAn in-message affordance (“Reply via SMS”, or your indicatorLabel, falling back to “View conversation”) shown on bridge-authored messages.
AppConversationSlideOutA panel showing the transcript + a composer. Sending posts the reply into the discussion as a normal message; the outbound dispatcher relays it.
End conversationBRIDGE_SESSION_END from the slide-out flips the session to ended; a later inbound message opens a fresh one.

Zero web changes  Per-app UI config (label, icon, whether the slide-out is offered) is data on the app registration, resolved through the shared UI registry — never a hardcoded if (bridgeType === 'twilio'). A new bridge that uses delivery_url needs no web changes at all.

Bridge-session control actions

The session lifecycle is exposed as first-party, TENANT-scoped, capability-checked actions (routed to the webhooks handler group):

ActionEffect
BRIDGE_SESSION_STARTExplicitly open a session for an external party (e.g. member-initiated outreach) and create its discussion.
BRIDGE_SESSION_ENDEnd the session.
BRIDGE_SESSION_STATUSReturn active · ended · not_found (+ discussionId).
BRIDGE_SESSION_LIST (query)Enumerate sessions for a bridge.

Outbound transports — PingStack → external

A member's reply must reach the external party through your app. Pick a transport in the manifest. delivery_url is the preferred, convergence target — it reuses the same signed, rate-limited, circuit-broken app-event-dispatcher as interaction/event delivery.

transportMechanismSigned?
delivery_urlPreferred. MESSAGE_CREATEDps_outbox → app-event-dispatcher → signed POST to the webhook's deliveryUrl. Your app relays to the external party.✅ platform HMAC (X-PingStack-Signature)
provider_restThe platform calls the provider's REST API directly under declared egress (e.g. Twilio api.twilio.com). Credential is a manifest secret parameter.provider auth (Basic)
poll_queueThe platform writes the reply to a provider-owned queue your app polls — no inbound socket to your app (how the Web Agent works).n/a (queue is app-owned)

provider_rest and poll_queue stay valid for providers that cannot host a signed inbound endpoint, but a new bridge SHOULD default to delivery_url.

Echo suppression (binding, all transports)

Loop guard  The outbound path MUST drop any reply whose senderUserId begins with a registered channel-kind prefix or webhook: — otherwise the app's own inbound message loops straight back out. The suppressed set is every channelKind plus webhook: (sms:, web:, email:, webhook:). This is a platform invariant, not per-app config.

Dispatch discipline (binding)

  • Recover the bridge from BRIDGE_META on the container (never scan), then load webhook + install from a fresh read and re-check capability — revocation/pause takes effect immediately.
  • Run off the ps_outbox stream (durable, at-least-once, retry/DLQ) — never a fire-and-forget POST from the browser. A reply must never depend on the browser staying open.
  • For delivery_url, sign with X-PingStack-Signature, send the timestamp + delivery headers, honor the outbound rate limit, and count failures toward the circuit breaker — identical to interaction/event delivery. No new signing scheme.
  • Write an APP_DELIVERY log row per attempt (observability parity).

Outbound delivery payload (delivery_url)

what your deliveryUrl receives — a signed conversation reply
{ "kind": "conversation", "version": 1, "type": "reply",
  "appId": "…", "installId": "…", "webhookId": "…",
  "scope": "TENANT", "tenantId": "…",
  "conversation": { "externalId": "sms:+15551234567", "discussionId": "…" },
  "message": { "messageId": "…", "seq": 4211, "body": "…", "richBody": "…",
               "attachments": [ /* refs */ ] },
  "actor": { "userId": "u_…", "displayName": "Chris" },
  "createdAt": "…" }
  • conversation.externalId is the same {prefix}:{id}; strip your prefix to recover the raw address (to.split(':').slice(1).join(':')).
  • Actor minimization: userId + displayName only. No email, roles, or username crosses to the third party.
  • The reply body is included — it is the point of the conversation.
  • At-least-once — dedupe on X-PingStack-Delivery.

Worked example — a “support-email” bridge

Onboarding a brand-new channel kind is a manifest plus a thin ingress Lambda — no projection handler, no web changes. Here is an email bridge end to end.

1 · Declare it in the manifest

support-email — manifest excerpt
{
  "appId": "support-email",
  "name": "Support Email",
  "interactionModel": "CREATE_CONTAINER",
  "scopes": ["webhooks.incoming", "messages.write", "container.manage", "webhooks.outgoing"],
  "egressPolicy": { "allowedDomains": ["hooks.acme-mail.example"] },
  "parameters": [
    { "name": "deliveryUrl", "type": "url" },
    { "name": "signingSecret", "type": "secret" }
  ],
  "conversations": {
    "enabled": true,
    "channelKind": "email",
    "externalIdLabel": "Email address",
    "session": { "autoEndMinutes": 60, "createsContainer": true },
    "inbound":  { "signature": { "secretParam": "signingSecret" } },
    "outbound": { "transport": "delivery_url", "deliveryUrlParam": "deliveryUrl" }
  }
}

2 · Ingress: relay an inbound email into a conversation

your ingress Lambda — inbound relay (pseudo-Node)
// 1. Verify the caller (provider HMAC over the raw bytes) — fail closed.
if (!verifyInboundSignature(headers, rawBody, signingSecret)) return { statusCode: 401 };

// 2. Fresh-read install + webhook state; reject if suspended/paused.
const { webhook, install } = await loadBridge(appId, webhookId);
assertActive(webhook, install);

// 3. Resolve the conversation by the stable external id.
const externalId = `email:${normalizeAddress(from)}`;         // email:jane@co.com
let session = await getActiveBridgeSession(webhookId, externalId, 60);
if (!session) {
  session = await createBridgeSession({
    webhookId, appId, externalId,
    externalLabel: `Email: ${from}`,          // rendered as the discussion title
    parentContainerId: webhook.containerId,
  });                                          // also writes BRIDGE_META on the discussion
}

// 4/5. Author as the external party + write message + outbox transactionally.
await buildMessageWrite({
  containerId: session.discussionId,
  senderUserId: externalId,                    // {prefix}:{id} — a synthetic sender
  isWebhookMessage: true, webhookId, appId,
  body: subjectAndText,                        // plaintext; the builder encrypts it
  attachments,
});

3 · Outbound: relay a member's reply back out

A member replies in the discussion (or the slide-out). The platform echo-suppresses your email: sender, recovers BRIDGE_META, and POSTs the signed conversation payload to your deliveryUrl. You verify and send:

your deliveryUrl endpoint — verify & relay (app-kit)
import { verifyPingStackRequest, parseDelivery } from '@pingstack/app-kit';

app.post('/pingstack', async (req, res) => {
  const raw = req.rawBody;                                  // exact bytes, not re-serialized
  if (!verifyPingStackRequest(req.headers, raw, SIGNING_SECRET)) return res.sendStatus(401);

  const delivery = parseDelivery(raw);
  if (delivery.kind === 'conversation' && delivery.type === 'reply') {
    const to = delivery.conversation.externalId.split(':').slice(1).join(':'); // jane@co.com
    await sendEmail({ to, text: delivery.message.body });   // dedupe on X-PingStack-Delivery
  }
  res.sendStatus(200);
});

That is the whole bridge. No platform-side handler, no per-provider outbound transport, and no web changes — the shared reply surface and signed dispatch do the rest. This is the generality test: if a new channel kind needs more than a manifest + thin ingress, something regressed.

Built-in bridges — SMS & Web Agent

PingStack ships two production instances of App Conversations: the SMS bridge (text a Twilio number, talk to a Server) and the Web Agent (an embeddable chat widget that bridges anonymous website visitors to your team). Both put the external party into a discussion your members answer from inside PingStack.

BridgechannelKindExternal partyInbound ingressOutbound transport
SMSsmsA phone number — sms:+15551234567Platform SMS ingress (the provider's incoming-message webhook)provider_rest — the platform calls the SMS provider's REST API
Web AgentwebAn anonymous visitor — web:vis_01HDEF…Platform session API (used by the widget)poll_queue — replies queued; the widget long-polls

Use them for:

  • SMS — customer support or alerts over plain text messaging. A customer texts your Twilio number; each phone number becomes its own conversation; MMS media arrives as normal attachments; a member's reply is sent back as an SMS from your number.
  • Web Agent — “chat with us” on any website. A visitor passes a CAPTCHA, gets a session, and chats with your team in real time — no account, no login, no WebSocket infrastructure on your site. Supports a welcome message, emailed transcripts, and a “Continue in PingStack” upgrade path.

Shared anatomy

Both bridges are the same machine with different edges. Components, in flow order:

ComponentRole
Provider ingressA platform-hosted endpoint per bridge (the SMS webhook receiver; the Web Agent session API). Authenticates the caller (provider signature / session token), resolves or starts the bridge session, authors the inbound message.
Bridge sessionThe platform's external id → discussion mapping — it is the conversation. Inactivity auto-end (default 30 min); a later message from the same party opens a fresh one.
The discussionBridged messages are normal PingStack messages — realtime delivery, unreads, search, and the reply surface come for free.
Outbound relayDurable, at-least-once dispatch of member replies back to the external party: echo-suppresses bridge senders, recovers the bridge from the container's bridge meta, then relays via the bridge's transport (provider REST for SMS; the reply queue for the Web Agent).
Session controlBRIDGE_SESSION_START/END/STATUS + list query — the slide-out’s “End conversation” etc.
WidgetThe embeddable chat UI (Web Agent only), served from the PingStack widget CDN.

What you can and can't change

Fixed — binding contracts. Changing any of these breaks verification, live data, or the platform loop guard:

ContractWhy it's fixed
Twilio signature scheme — X-Twilio-Signature, Base64 HMAC-SHA1 over url + sorted(key+value)Defined by Twilio; the bridge fails closed on mismatch (retries once after cache refresh on secret rotation).
External-id format {prefix}:{id} and the registered channel kinds (sms, web, email reserved)The join key across session keys, senderUserId, echo suppression, and outbound payloads.
Echo suppression of sms: / web: / email: / webhook: sendersPlatform invariant — without it the bridge's own inbound message loops back out.
URL shapes — inbound POST /{appId}/{webhookId}, outbound POST /send/{appId}/{webhookId}, widget /session/* routesConfigured in the provider console / embedded in deployed widgets.
Web Agent session token — a signed JWT carrying the session, webhook/app/tenant, and discussion idsVerified on every widget call; treat it as opaque.
CAPTCHA on /session/start + rate limitingThe abuse gate for an unauthenticated public endpoint.
Conversation resolution — one active session per external party per bridgeAn inbound message always routes to the party's active session, or opens a new one if none.

Configurable — per install/webhook (or a code constant you can tune deliberately):

KnobWhere
autoEndMinutes — inactivity timeout (default 30). One knob for both ends: PingStack lazy auto-end of the bridge session and your BYO website-chat idle close (echoed as session.autoEndMinutes on ingress).Manifest conversations.session.autoEndMinutes → webhook config.bridgeSession.autoEndMinutes (set in Add Custom App → Idle timeout)
Bound channel — which container new conversations hang offWebhook binding (WEBHOOK_CREATE)
Twilio accountSid + fromNumberInstallation / webhook config
welcomeMessage, webAgent.allowedOriginsInstallation config (Web Agent)
Reply label / icon / externalIdLabel in the reply surfaceApp manifest (UI registry — never hardcoded per bridge)
Widget gateway URL + lookEmbed data-* attributes

SMS bridge — setup

Provider  The first-party SMS bridge is backed by Twilio today. The framework itself is provider-agnostic — the sms channel kind, sessions, echo suppression, and the reply surface don't know or care which carrier API sits at the edge. To bridge a different SMS provider now, run a thin relay against the hosted inbound + delivery_url outbound, exactly like the worked example.

Setup is a Server-admin flow — no code on your side:

  1. Install the SMS bridge app on your Server from the App Store.
  2. Provide your Twilio credentials — the Account SID on the installation, and the auth token as the webhook's managed secret (stored server-side, shown once; it verifies X-Twilio-Signature and authenticates outbound sends).
  3. Bind a webhook to the channel that should receive texts, with your Twilio number as its fromNumber.
  4. Point Twilio at the bridge — in the Twilio console, set the phone number's incoming-message webhook (HTTP POST) to the SMS ingress URL shown on the webhook you created (…/{appId}/{webhookId}).

Inbound flow: Twilio POSTs form-encoded fields → signature verified → session resolved by sms:{from} → message authored into the discussion → empty TwiML returned (<Response></Response>) so Twilio never auto-replies. A member's reply is relayed back out through the Twilio REST API from your number.

SMS payloads

Inbound (Twilio → bridge)application/x-www-form-urlencoded; the fields the bridge reads:

FieldMeaning
From / ToSender and receiving Twilio number (E.164).
BodySMS text.
MessageSidTwilio message id (also accepted as SmsSid).
NumMedia, MediaUrl{i}, MediaContentType{i}MMS attachments — mapped to the message's attachments array.
what Twilio sends (decoded)
From=+15551234567&To=+15559876543&Body=Hi, I need help with my order
&MessageSid=SM8c2f…&NumMedia=1
&MediaUrl0=https://api.twilio.com/…/Media/ME7a…&MediaContentType0=image/jpeg

# header: X-Twilio-Signature: <Base64 HMAC-SHA1(authToken, url + sorted(key+value))>

Outbound REST endpointPOST {ingress-url}/send/{appId}/{webhookId} (used by the platform relay; also callable for member-initiated outreach):

outbound send body
{ "to": "+15551234567",            // "sms:+1555…" is accepted; the prefix is stripped
  "body": "Your order shipped 📦",
  "mediaUrls": ["https://cdn.dev.pingstack.app/…"] }   // optional (MMS)

The inbound message lands authored as senderUserId: "sms:+15551234567" with isWebhookMessage: true — a synthetic sender, never a ps_users identity (see External identity).

Web Agent — setup

Beta  The Web Agent is currently available in the development environment while it hardens toward general availability.

  1. Install the Web Agent app on your Server, and create a webhook bound to the channel your team answers from, with bridge sessions enabled.
  2. Configure it — optional welcomeMessage (shown to the visitor on session start) and allowedOrigins (the sites permitted to embed your widget; requests from other origins are rejected).
  3. Embed the widget on your site with the snippet shown on the webhook's Configure page:
widget embed
<script src="https://widget.dev.pingstack.app/v1/widget.js"
        data-tenant-id="t_…"
        data-app-id="web-agent"
        data-webhook-id="wh_…"
        data-gateway-url="https://web-agent.dev.pingstack.app"></script>

Gateway API (all session routes carry the session token in the path; CORS-checked against allowedOrigins):

RouteDoes
POST /session/startCAPTCHA + rate-limit check → creates the discussion + bridge session → returns a session JWT.
POST /session/{token}/sendVisitor sends a message (authored as web:{sessionId}).
GET /session/{token}/poll?after={seq}Long-poll (≤ 20 s) the reply queue for agent messages.
POST /session/{token}/endEnd the session.
POST /session/{token}/transcriptEmail the transcript to the visitor.
POST /session/{token}/upgrade-tokenMint the “Continue in PingStack” upgrade token.

Web Agent payloads

POST /session/start — request → response
// request
{ "webhookId": "wh_…", "appId": "web-agent", "tenantId": "t_…",
  "visitorName": "Jane", "visitorEmail": "jane@co.com",      // email optional
  "captchaToken": "<turnstile token>", "pageUrl": "https://acme.com/pricing" }

// response
{ "sessionToken": "<HS256 JWT>", "sessionId": "vis_01HDEF…",
  "welcomeMessage": "Hi! How can we help?", "expiresAt": "2026-07-16T20:00:00Z" }

Session JWT claims (verified on every call): sid (session id), wid/aid/tid (webhook/app/tenant), did (the discussion container), name, optional email, iat/exp.

send + poll
// POST /session/{token}/send
{ "body": "Do you have an API?" }                 // optional "richBody" (markdown)
// → { "messageId": "…", "seq": 7 }

// GET /session/{token}/poll?after=7   (long-polls up to ~20 s)
{ "messages": [
    { "seq": 8, "body": "We do — docs.pingstack.app!", "senderName": "Chris",
      "senderAvatar": "https://…", "isAgent": true, "timestamp": "2026-07-16T19:02:11Z" }
  ],
  "lastSeq": 8, "sessionState": "active", "pollAfterMs": 2000 }

Agent replies reach the widget through the poll_queue transport: the platform queues each member reply per session, and the widget's poll drains the queue by seq. There is no socket to the visitor's browser and no inbound endpoint on your site.

Examples — ship a bridge

Practical recipes on the same App Platform surface: create a custom app, sign ingress, receive signed deliveries. Each example deep-links into the API sections above.

BYO  PingStack never runs your bot code. You bring a Lambda (or any HTTPS host). The platform owns the webhook URL, signing secret, discussions, and reply UI. Prefer delivery_url for third-party outbound (Outbound transports).

ExampleChannelOutboundJump
Website chat (BYO) web delivery_url Create app · Inbound · Signing
SMS / Twilio sms provider_rest SMS setup · Payloads
MCP connectivity mcp (app-owned) cards + delivery_url interactions Session directives · Components · Callbacks

Example — website chat (BYO)

Build “chat with us” without installing the first-party Web Agent. You own a small Lambda + embed; PingStack owns the team inbox via a Conversation bridge with channel kind web and outbound delivery_url.

Timers  One idle window, both ends. Set Idle timeout in Add Custom App (or conversations.session.autoEndMinutes in the manifest). PingStack uses it for lazy bridge auto-end; webhook-ingress echoes the same value as session.autoEndMinutes on create/ensure so your visitor Lambda/widget closes on the same clock. Do not invent a second timeout on the visitor side unless you need a fallback when the field is absent (default 30).

1 · Create the app

  1. Server Owner/Admin → App Store → + Add Custom App (UI wizard).
  2. Kind: Conversation bridge.
  3. Provider: My own endpoint (not Twilio).
  4. Channel kind: Website chat (web) — channelKind registry.
  5. Idle timeout (minutes) — sets conversations.session.autoEndMinutes once for both the agent slide-out / bridge session and your visitor Lambda/widget. Default 30.
  6. Outbound: Signed delivery URLhttps://<your-lambda>/delivery (delivery_url). Keep this on the Function URL (server-to-server); put the browser API on your own hostname.
  7. Bind (or create) a home channel, e.g. www-chat.
  8. Copy the inbound Webhook URL + Signing secret (API reference).
manifest — timer (same knob the wizard writes)
"conversations": {
  "enabled": true,
  "channelKind": "web",
  "externalIdLabel": "Web visitor",
  "session": {
    "autoEndMinutes": 30,     // Idle timeout — ONE window for PingStack + BYO
    "createsContainer": true
  },
  "outbound": {
    "transport": "delivery_url",
    "deliveryUrlParam": "deliveryUrl"
  }
}

2 · Example Lambda shape

Browser routes never see PingStack secrets. The Lambda signs inbound HMAC to your webhook URL and verifies X-PingStack-Signature on /delivery. A full customer-shaped reference (Function URL, DynamoDB sessions, Turnstile, CloudFront hostname, widget) lives at azimuthsystems.net/showcase/web-chat (source under that site’s examples/pingstack-web-chat/).

Lambda — visitor send → PingStack (first message)
// 1) Root message in the bound channel (gets seq)
const rootBody = JSON.stringify({
  body: `${visitorName} started a web chat`,
  username: visitorName,
});
const root = await postIngress(rootBody); // X-Webhook-Signature: sha256=…

// 2) Open web:{visitorId} session rooted on that seq (§ session directives)
const first = JSON.stringify({
  body: text,
  username: visitorName,
  session: {
    externalId: visitorId,           // → web:{visitorId}
    label: `Web: ${visitorName}`,    // composer shows "Web: Name"
    parentSeq: root.seq,
    ensure: true,
  },
});
const created = await postIngress(first);
// Prefer PingStack's idle window (same value as agent-side lazy auto-end):
// created.session.autoEndMinutes  ←  config.bridgeSession.autoEndMinutes
const idleMinutes = created.session?.autoEndMinutes ?? process.env.IDLE_TIMEOUT_MINUTES ?? 30;
// Drive visitor idle close + countdown from idleMinutes (do not hardcode a second timer).

Opening a new discussion needs parentSeq — see Session directives. Later messages reuse the same session.externalId (idempotent). The ingress response includes session.autoEndMinutes — use that for your visitor idle timer so both ends share one configured value. Simpler one-shot posts without a discussion can use hosted inbound externalId alone (Hosted inbound).

Lambda — /delivery (member reply + session_end)
// Verify: X-PingStack-Signature = v1=HMAC-SHA256(secret, `${timestamp}.${rawBody}`)
// Envelope: see Outbound transports
{ "kind": "conversation", "version": 1, "type": "reply",
  "conversation": { "externalId": "web:vis_…", "discussionId": "…" },
  "message": { "body": "We do — here's a link…", "…" },
  "actor": { "userId": "u_…", "displayName": "Chris" } }

// When an agent ends the App Session (or PingStack lazy-ends it), you also receive:
{ "kind": "conversation", "version": 1, "type": "session_end",
  "conversation": { "externalId": "web:vis_…", "discussionId": "…" } }
// → mark the visitor session ended; poll returns sessionState: "ended"

Suggested Function URL routes:

RouteRole
POST /session/startCreate visitor id; return session to the embed (CAPTCHA here).
POST /session/{id}/sendHMAC POST into PingStack (above).
GET /session/{id}/pollLong-poll replies buffered from /delivery; include sessionState.
POST /session/{id}/endVisitor ends → session.end into PingStack (+ optional transcript email).
POST /deliveryPingStack → you (signed reply and session_end).

3 · Website embed

Point the browser only at your public chat API (your domain → Lambda). Never put the PingStack webhook URL or signing secret in page JS.

embed — browser talks only to YOUR API
<script
  src="https://your.cdn.example/chat/widget.js"
  data-api-base="https://chat.example.com"
  data-turnstile-site-key="0x4AAAA…"
  data-theme-color="#4c8dff"
  data-position="bottom-right"></script>
<!-- Optional deep link: open with ?chat=1 -->

Contrast  The hosted Web Agent uses a PingStack CDN widget + poll_queue gateway (CAPTCHA, JWT). This example is the portable framework path: same conversations primitives, your compute.

Also see: Worked email example (same delivery_url pattern) · @pingstack/app-kit · live showcase azimuthsystems.net/showcase/web-chat.

Example — SMS / Twilio

Bidirectional SMS on channel kind sms. Today’s hosted path uses the Twilio conversation provider (credentials collected in the wizard; ingress URL stamped server-side). Reference: SMS bridge — setup and SMS payloads.

1 · Create via the Conversation bridge wizard

  1. + Add Custom App → kind Conversation bridge (Create a custom app).
  2. Provider: Twilio (locks channel kind sms and outbound provider_rest — see Outbound transports).
  3. Enter Account SID, Auth Token (signing secret), and From number.
  4. Bind a home channel for SMS discussions.
  5. In Twilio Console → the number → Messaging webhook → paste the SMS ingress URL shown on the webhook (…/{appId}/{webhookId}.

Note  Older docs described a first-party “SMS bridge” App Store install. New installs use the generic provider wizard above. The wire formats in SMS payloads are unchanged.

2 · What crosses the wire

Twilio → PingStack (form body)
From=+15551234567&To=+15559876543&Body=Hi, I need help
&MessageSid=SM8c2f…
# X-Twilio-Signature: <Base64 HMAC-SHA1(authToken, url + sorted fields)>

Sessions key as sms:+15551234567 (External identity). Echo suppression drops sms: senders on the way out (Echo suppression).

3 · BYO SMS provider (no Twilio)

Use My own endpoint + channel kind SMS + delivery_url, then relay with hosted inbound exactly like the support-email worked example. Your Lambda verifies the carrier’s signature and POSTs signed JSON to the PingStack webhook URL.

Example — MCP connectivity

Bridge an MCP / work-item system into a Server channel as app-owned sessions (channelKind: mcp). The bot opens a discussion per work id, posts status cards, and receives button clicks on your deliveryUrl. Members do not end these sessions — only the app does (Session directives, channelKind).

1 · Create the app

  1. + Add Custom AppBot or Conversation bridge with channel kind suitable for app-owned work (when using conversations, set prefix mcp via the bridge config the wizard writes — wizard).
  2. Grant at least webhooks.incoming, messages.write, ui.components, and webhooks.outgoing (scopes) so you can post cards and receive interactions.
  3. Set Outbound delivery URL to https://<your-lambda>/pingstack (Interaction callbacks).
  4. Bind an ops channel. Copy webhook URL + signing secret (API reference).

2 · Post a root card, then open a session

MCP bridge — root card + session child
// A) Root card in the ops channel
const root = await postIngress(JSON.stringify({
  richBody: "**Work** `w-ms20ymhp-0` needs attention",
  components: [ /* approve / open cockpit — see Interactive components */ ],
}));

// B) Follow-ups INTO an mcp: session (idempotent on externalId)
await postIngress(JSON.stringify({
  richBody: "Diagnosis complete — awaiting approve.",
  components: [ /* status / progress blocks */ ],
  session: {
    externalId: "w-ms20ymhp-0",     // → mcp:w-ms20ymhp-0
    label: "Ship the thing",
    parentSeq: root.seq,
    ensure: true,
  },
}));

Card schema: Interactive components, Live activity, Selects & modals. Prefer @pingstack/app-kit builders.

3 · Handle interactions + close

Lambda — /pingstack delivery + session end
// Verify X-PingStack-Signature (see Request signing)
// Interaction envelope → isInteraction() from app-kit
if (isInteraction(delivery)) {
  // call your MCP decide / Intent API…
  await postIngress(JSON.stringify({
    op: "update",
    messageId: delivery.message.messageId,
    seq: delivery.message.seq,
    components: [ /* flip buttons to Approved ✓ */ ],
  }));
}

// When the work item closes — app-owned end
await postIngress(JSON.stringify({
  session: { externalId: "w-ms20ymhp-0", end: true },
}));

Full interactive walkthrough: Interaction callbacks, Event subscriptions, Update a message. Reference integrations in the PingStack repo (integrations/sentry-autofix-bot, integrations/sentinel-bridge) follow this dual-route pattern (/mcp or /sentry in, /pingstack for callbacks).

Request signing

Every request across the app boundary is HMAC-SHA256 signed with the per-webhook secret. Verify over the raw bytes, timing-safe.

Inbound — you → PingStack

HeaderX-Webhook-Signature: sha256=<hex>
ValueHMAC-SHA256(secret, rawBody)

Outbound — PingStack → you

HeadersX-PingStack-Signature: v1=<hex> · X-PingStack-Timestamp · X-PingStack-Delivery
ValueHMAC-SHA256(secret, `${timestamp}.${rawBody}`)
Replay window300 seconds — reject older timestamps.

Rotate  Rotate the signing secret any time from Configure → Webhooks → Rotate. The same secret authenticates both directions; rotation is immediate.

Security & data boundaries

These are binding platform invariants, not guidelines:

  • Zero ambient authority. An app can do exactly what its installation was granted — nothing else is reachable, inferable, or enumerable. Capabilities not granted are denied.
  • Re-checked every time. Capabilities are validated from a fresh read on every operation and every delivery. Revoking a scope or suspending an app takes effect immediately.
  • Secrets are managed. Signing secrets live in a secrets manager and are shown to you once. PingStack never stores them in plaintext data records.
  • Egress is screened. Outbound delivery targets are SSRF-screened (HTTPS only, no private/metadata IPs, no redirects) and rate-limited, with a circuit breaker that auto-pauses a failing endpoint.

Tenant isolation — never crossed

Absolute  An app or webhook operating in Server A MUST NEVER access, infer, enumerate, or act on any resource of Server B — under any circumstances, and even when it is the same app installed in both Servers.

  • Installs, webhooks, signing secrets, and delivery logs are partitioned per Server. A webhook or secret minted for one Server is never valid for another.
  • An app's granted scopes in Server A confer zero authority in Server B.
  • Home (personal) and Server scope are likewise hard-isolated: a Home app can never touch a Server's data, and vice-versa.

API reference

Provisioning (gateway actions)

Server admins provision apps through the gateway (from the App Store UI, or executeAction). Each is RBAC-gated and re-applies the Server policy ceiling.

ActionDoes
APP_REGISTERCreate an app entry (requires app.manage).
APP_INSTALLInstall to a Server with granted scopes (requires app.install).
APP_UPDATE_CONFIGEdit granted scopes / config / state (suspend).
WEBHOOK_CREATEMint an inbound webhook + signing secret bound to a container.
WEBHOOK_ROTATE_SECRETRotate the signing secret.
APP_INTERACTION_SUBMIT(internal) button / select / modal / command; PingStack delivers it to your deliveryUrl.

Webhook ingress

EndpointPOST {webhook-url}/{appId}/{webhookId} (the full URL is shown when you create the app).
AuthX-Webhook-Signature header (see Signing).
Opsdefault = post a message · op:"update" = patch an authored message.
Rate limit60 requests/min per webhook (default; policy-adjustable).

SDK — @pingstack/app-kit

A zero-dependency helper for signing, building components, and parsing deliveries.

ExportUse
signWebhookRequest(raw, secret)Header value for your inbound POSTs.
verifyPingStackRequest(headers, raw, secret)Verify + replay-check an outbound delivery.
button() · row() · card()Build valid component cards (author-time limit checks).
select() · status() · progress()v2 select + live-activity blocks.
modal() · textField() · selectField()v2 modal definitions (attach via button({ modal })).
messageWithComponents() · updateMessage()Build post / update payloads.
parseDelivery() · isInteraction() · isEvent()Typed parsing of inbound deliveries.

PingStack developer documentation · Server = Tenant · App Interactions v1/v2 (Server scope). Binding contracts live in the repo under docs/APP_PLATFORM/CONTRACTS/.