PingStack apps & bots

Extend a PingStack Server with apps, bots, and webhooks — post messages, add interactive buttons, react to events, and bridge external systems, all under explicit, revocable permissions.

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 interactive components (buttons) and receives interaction callbacks when users click them.

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 four steps and provisions everything for you:

StepWhat you set
DetailsName + description.
BehaviorInteraction model: POST_TO_EXISTING_CONTAINER, CREATE_CONTAINER, or INVITABLE.
PermissionsThe scopes the app is granted (see below).
BindingThe channel to post into (or a new one it creates).

On finish you get the webhook URL + signing secret. 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 buttons.
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

v1 supports buttons, arranged in rows. Send them as a components array; PingStack stores them as a versioned components card and renders them in the message.

Button gallery

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

Limits  2 rows per message · 5 buttons per row · 4000-char markdown · 16 KB serialized card. Interactive components are Server (Tenant) scope only in v1.

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,
});

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 clicks a button with an actionId, PingStack POSTs a signed interaction delivery to your app's deliveryUrl. It is minimal by design — the actor is reduced to userId + displayName. Verify the signature, then respond by updating the original message.

Node — receive & verify a click
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)) {
    const { actionId, value, actor, messageId, seq } = delivery;
    // ... do work, then POST an op:"update" back to the webhook URL.
  }
  res.sendStatus(200);
});

Deliveries are at-least-once — dedupe on interactionId.

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).

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) a button click; 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).
messageWithComponents() · updateMessage()Build post / update payloads.
parseDelivery() · isInteraction() · isEvent()Typed parsing of inbound deliveries.

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