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
| Concept | What it is |
|---|---|
| App | A registered extension (appId). Has a name, requested scopes, and a category (native, curated, or custom). |
| Installation | Binds an app to one execution context — a Server (TENANT) or a user's Home. Carries the granted scopes. |
| Webhook | An 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. |
| Scope | A capability the app is granted (e.g. messages.write). Enforced server-side; capped by the Server's policy ceiling. |
| Container | A 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:
- Open your Server → App Store → “+ Add Custom App” (Server Admin/Owner only).
- Name it, pick permissions, choose how it interacts (post to a channel, create its own channel, or be invitable), and finish.
- Copy the Webhook URL and signing secret shown on the final screen (the secret is shown once).
- POST a signed JSON body to that URL — it appears as a message from your bot.
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:
| Step | What you set |
|---|---|
| Details | Name + description. |
| Behavior | Interaction model: POST_TO_EXISTING_CONTAINER, CREATE_CONTAINER, or INVITABLE. |
| Permissions | The scopes the app is granted (see below). |
| Binding | The 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.
| Scope | Allows |
|---|---|
webhooks.incoming | Receive inbound webhooks (post messages in). |
messages.write | Post messages. |
messages.read | Read message content delivered via subscriptions. |
messages.history | Read prior message history (separately gated). |
ui.components | Post interactive buttons. |
reactions.write | Add reactions. |
members.read | Read the member list. |
webhooks.outgoing | Receive outbound event subscriptions. |
Post a message
POST JSON to your webhook URL. The message is authored as your bot in the bound channel.
| Field | Type | Notes |
|---|---|---|
body / text | string | Plain-text body. |
richBody / markdown | string | Markdown body (≤ 4000 chars). |
username | string | Override the display name for this post. |
avatarUrl | string | Override the avatar for this post. |
components | array | Interactive 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
| Field | Notes |
|---|---|
componentId | Unique per message; ^[A-Za-z0-9_.:-]{1,64}$. |
label | Plain text, ≤ 40 chars. |
actionId xor url | Either a click that calls back your app, or a link. Not both. |
style xor color | default·primary·success·warning·danger·ghost, or a named palette color. |
value | Opaque, ≤ 1024 bytes; echoed back to you on click. |
confirm | Optional confirmation prompt before the callback fires. |
requiredPermission | container.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.
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.
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.
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
| Header | X-Webhook-Signature: sha256=<hex> |
| Value | HMAC-SHA256(secret, rawBody) |
Outbound — PingStack → you
| Headers | X-PingStack-Signature: v1=<hex> · X-PingStack-Timestamp · X-PingStack-Delivery |
| Value | HMAC-SHA256(secret, `${timestamp}.${rawBody}`) |
| Replay window | 300 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.
| Action | Does |
|---|---|
APP_REGISTER | Create an app entry (requires app.manage). |
APP_INSTALL | Install to a Server with granted scopes (requires app.install). |
APP_UPDATE_CONFIG | Edit granted scopes / config / state (suspend). |
WEBHOOK_CREATE | Mint an inbound webhook + signing secret bound to a container. |
WEBHOOK_ROTATE_SECRET | Rotate the signing secret. |
APP_INTERACTION_SUBMIT | (internal) a button click; PingStack delivers it to your deliveryUrl. |
Webhook ingress
| Endpoint | POST {webhook-url}/{appId}/{webhookId} (the full URL is shown when you create the app). |
| Auth | X-Webhook-Signature header (see Signing). |
| Ops | default = post a message · op:"update" = patch an authored message. |
| Rate limit | 60 requests/min per webhook (default; policy-adjustable). |
SDK — @pingstack/app-kit
A zero-dependency helper for signing, building components, and parsing deliveries.
| Export | Use |
|---|---|
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/.