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
| 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 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:
- 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 provisioning. Pick a kind first:
| Kind | Use when |
|---|---|
| Bot | Interactive cards, slash commands, event subscriptions — see Interactive components. |
| Incoming webhook | One-way post into a channel (CI, alerts) — see Quickstart. |
| Conversation bridge | Two-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:
| Step | What you set |
|---|---|
| Details | Name + description. |
| Provider | A hosted provider (e.g. Twilio for SMS) or My own endpoint (you host ingress + choose delivery_url / poll_queue). |
| Channel kind | Locked by the provider, or chosen for “My own endpoint” (sms, web, …). |
| Idle timeout | Minutes of inactivity before the bridge session ends (default 30). Writes conversations.session.autoEndMinutes — one value for both ends (PingStack lazy auto-end and your BYO visitor idle close). See Website chat. |
| Permissions | Conversation scopes are auto-granted; add more only if needed (see scopes). |
| Binding | Home 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.
| 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 component cards (buttons, selects, status/progress). |
ui.modals | Attach modal dialogs to buttons (collect structured input). |
ui.slash_commands | Register slash commands in the app manifest. |
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
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
| Field | Notes |
|---|---|
componentId | Unique per message; ^[A-Za-z0-9_.:-]{1,64}$. |
label | Plain text, ≤ 40 chars. |
actionId xor url xor modal | Callback click, link, or open a modal. Exactly one. |
style xor color | default·primary·success·warning·danger·ghost, or a named palette color. |
value | Opaque, ≤ 1024 bytes; echoed back on click / select. |
confirm | Optional confirmation prompt before the callback fires. |
requiredPermission | container.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).
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.
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.
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.
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.
type | When | Key fields |
|---|---|---|
button_click | Button with actionId | component.actionId, component.value |
select | Inline select changed | component.actionId, selectedValues |
modal_submit | Modal submitted | modal.actionId, modal.fields |
command | Slash command invoked | command.name, command.text / options |
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
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
| Term | What it is |
|---|---|
| Bridge | An 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 party | The 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 session | The mapping external id → discussion container. It is the conversation. Has a lifecycle (active/ended) with lazy inactivity auto-end. |
| Bridge meta | The reverse pointer on the container that lets the outbound path recover the bridge/app/external-id from a reply — no scan. |
| Reply surface | The 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.
"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:
scopesmust includewebhooks.incomingandmessages.write; a session that creates containers also needscontainer.manage; a signeddelivery_urloutbound also needswebhooks.outgoing.interactionModelmust beCREATE_CONTAINERwhensession.createsContainer: true. A single fixed-thread bridge usesPOST_TO_EXISTING_CONTAINERwithcreatesContainer: false.- Every outbound host — in
egressDomains(forprovider_rest) or resolved fromdeliveryUrlParam(fordelivery_url) — must be inegressPolicy.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}$.
channelKind | External id example | Status |
|---|---|---|
sms | sms:+15551234567 | live (Twilio) |
web | web:vis_01HDEF | live (Web Agent) |
email | email:jane@co.com | reserved |
webhook | — | forbidden 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:
- The bridge session sort key —
SESSION#{externalId}. - The authored message's
senderUserId—{prefix}:{id}. - Echo suppression — outbound drops any message whose
senderUserIdstarts with a registered channel-kind prefix. - 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):
- 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. - Check install + webhook state from a fresh read — a suspended install or paused webhook is rejected.
- Resolve the conversation: compute
externalId = {channelKind}:{normalizedId}and callgetActiveBridgeSession(webhookId, externalId).- Hit → route the message into that
discussionIdand bump activity. - Miss → if
createsContainer, post a “new conversation” system message into the bound parent, create the discussion, open the session, and writeBRIDGE_META. Otherwise post into the fixed bound container.
- Hit → route the message into that
- 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 normalattachmentsarray. - 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.
| Field | Notes |
|---|---|
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. |
externalLabel | Optional display name for the party (falls back to externalId). |
body / richBody | The inbound message text. |
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.
{ "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
externalIdis prefixed with the webhook’sexternalIdPrefix(e.g.mcp:). - Resolution is idempotent on
(webhookId, externalId). - Response includes
session: { externalId, discussionId, created, ended? }. - Members may end
sms/websessions; only the app may endmcp(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:
| Surface | What it does |
|---|---|
AppConversationIndicator | An in-message affordance (“Reply via SMS”, or your indicatorLabel, falling back to “View conversation”) shown on bridge-authored messages. |
AppConversationSlideOut | A panel showing the transcript + a composer. Sending posts the reply into the discussion as a normal message; the outbound dispatcher relays it. |
| End conversation | BRIDGE_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):
| Action | Effect |
|---|---|
BRIDGE_SESSION_START | Explicitly open a session for an external party (e.g. member-initiated outreach) and create its discussion. |
BRIDGE_SESSION_END | End the session. |
BRIDGE_SESSION_STATUS | Return 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.
transport | Mechanism | Signed? |
|---|---|---|
delivery_url | Preferred. MESSAGE_CREATED → ps_outbox → app-event-dispatcher → signed POST to the webhook's deliveryUrl. Your app relays to the external party. | ✅ platform HMAC (X-PingStack-Signature) |
provider_rest | The 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_queue | The 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_METAon 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_outboxstream (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 withX-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_DELIVERYlog row per attempt (observability parity).
Outbound delivery payload (delivery_url)
{ "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.externalIdis the same{prefix}:{id}; strip your prefix to recover the raw address (to.split(':').slice(1).join(':')).- Actor minimization:
userId+displayNameonly. No email, roles, or username crosses to the third party. - The reply
bodyis 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
{
"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
// 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:
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.
| Bridge | channelKind | External party | Inbound ingress | Outbound transport |
|---|---|---|---|---|
| SMS | sms | A phone number — sms:+15551234567 | Platform SMS ingress (the provider's incoming-message webhook) | provider_rest — the platform calls the SMS provider's REST API |
| Web Agent | web | An 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:
| Component | Role |
|---|---|
| Provider ingress | A 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 session | The 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 discussion | Bridged messages are normal PingStack messages — realtime delivery, unreads, search, and the reply surface come for free. |
| Outbound relay | Durable, 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 control | BRIDGE_SESSION_START/END/STATUS + list query — the slide-out’s “End conversation” etc. |
| Widget | The 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:
| Contract | Why 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: senders | Platform 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/* routes | Configured in the provider console / embedded in deployed widgets. |
| Web Agent session token — a signed JWT carrying the session, webhook/app/tenant, and discussion ids | Verified on every widget call; treat it as opaque. |
CAPTCHA on /session/start + rate limiting | The abuse gate for an unauthenticated public endpoint. |
| Conversation resolution — one active session per external party per bridge | An 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):
| Knob | Where |
|---|---|
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 off | Webhook binding (WEBHOOK_CREATE) |
Twilio accountSid + fromNumber | Installation / webhook config |
welcomeMessage, webAgent.allowedOrigins | Installation config (Web Agent) |
Reply label / icon / externalIdLabel in the reply surface | App manifest (UI registry — never hardcoded per bridge) |
| Widget gateway URL + look | Embed 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:
- Install the SMS bridge app on your Server from the App Store.
- 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-Signatureand authenticates outbound sends). - Bind a webhook to the channel that should receive texts, with your
Twilio number as its
fromNumber. - 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:
| Field | Meaning |
|---|---|
From / To | Sender and receiving Twilio number (E.164). |
Body | SMS text. |
MessageSid | Twilio message id (also accepted as SmsSid). |
NumMedia, MediaUrl{i}, MediaContentType{i} | MMS attachments — mapped to the message's attachments array. |
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 endpoint — POST {ingress-url}/send/{appId}/{webhookId}
(used by the platform relay; also callable for member-initiated outreach):
{ "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.
- Install the Web Agent app on your Server, and create a webhook bound to the channel your team answers from, with bridge sessions enabled.
- Configure it — optional
welcomeMessage(shown to the visitor on session start) andallowedOrigins(the sites permitted to embed your widget; requests from other origins are rejected). - Embed the widget on your site with the snippet shown on the webhook's Configure page:
<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):
| Route | Does |
|---|---|
POST /session/start | CAPTCHA + rate-limit check → creates the discussion + bridge session → returns a session JWT. |
POST /session/{token}/send | Visitor 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}/end | End the session. |
POST /session/{token}/transcript | Email the transcript to the visitor. |
POST /session/{token}/upgrade-token | Mint the “Continue in PingStack” upgrade token. |
Web Agent payloads
// 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.
// 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).
| Example | Channel | Outbound | Jump |
|---|---|---|---|
| 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
- Server Owner/Admin → App Store → + Add Custom App (UI wizard).
- Kind: Conversation bridge.
- Provider: My own endpoint (not Twilio).
- Channel kind: Website chat (
web) — channelKind registry. - Idle timeout (minutes) — sets
conversations.session.autoEndMinutesonce for both the agent slide-out / bridge session and your visitor Lambda/widget. Default 30. - Outbound: Signed delivery URL →
https://<your-lambda>/delivery(delivery_url). Keep this on the Function URL (server-to-server); put the browser API on your own hostname. - Bind (or create) a home channel, e.g.
www-chat. - Copy the inbound Webhook URL + Signing secret (API reference).
"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/).
// 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).
// 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:
| Route | Role |
|---|---|
POST /session/start | Create visitor id; return session to the embed (CAPTCHA here). |
POST /session/{id}/send | HMAC POST into PingStack (above). |
GET /session/{id}/poll | Long-poll replies buffered from /delivery; include sessionState. |
POST /session/{id}/end | Visitor ends → session.end into PingStack (+ optional transcript email). |
POST /delivery | PingStack → 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.
<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
- + Add Custom App → kind Conversation bridge (Create a custom app).
- Provider: Twilio (locks channel kind
smsand outboundprovider_rest— see Outbound transports). - Enter Account SID, Auth Token (signing secret), and From number.
- Bind a home channel for SMS discussions.
- 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
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
- + Add Custom App → Bot or
Conversation bridge with channel kind suitable for app-owned work
(when using conversations, set prefix
mcpvia the bridge config the wizard writes — wizard). - Grant at least
webhooks.incoming,messages.write,ui.components, andwebhooks.outgoing(scopes) so you can post cards and receive interactions. - Set Outbound delivery URL to
https://<your-lambda>/pingstack(Interaction callbacks). - Bind an ops channel. Copy webhook URL + signing secret (API reference).
2 · Post a root card, then open a session
// 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
// 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
| 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) button / select / modal / command; 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). |
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/.