Storage & webhooks

Two things the platform keeps for you so a small app needs no database of its own: a per-install key–value store, and webhook delivery for every workspace that installs you. Both are addressed by the install, so an access token is all you need; neither takes a scope.

App data storage

A settings and state store, keyed by install and mode. A zero-backend app keeps its configuration here; an app with a real backend keeps its own data and uses this for the few things that belong with the install.

GET    /v1/app-data              # list this install's keys (values omitted)
GET    /v1/app-data/:key         # one value
PUT    /v1/app-data/:key         # { "value": <any JSON> } — create or replace
DELETE /v1/app-data/:key
JavaScript
await fetch('https://api.paysio.com/v1/app-data/settings', {
  method: 'PUT',
  headers: { Authorization: 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
  body: JSON.stringify({ value: { emailCustomer: true, template: 'default' } }),
})

const res = await fetch('https://api.paysio.com/v1/app-data/settings', {
  headers: { Authorization: 'Bearer ' + accessToken },
})
const { value } = await res.json()   // { emailCustomer: true, template: 'default' }
RuleDetail
Keys1–128 characters of letters, digits, . _ : -. Up to 500 per install.
ValuesAny JSON, up to 64KB serialised.
ModePart of the primary key. A test-mode token cannot see live rows, and vice versa.
AccessApp tokens only. A merchant API key gets 403 app_required: there is no install for it to address.
LifetimeKept 30 days after uninstall, then purged. A merchant who reinstalls within that window finds their settings intact.
Errorsparameter_invalid for a bad key or oversized value; limit_exceeded at 500 keys; resource_missing on GET/DELETE of an unknown key.

Webhooks

Set webhooks.url in paysio.app.json and we provision an endpoint for every workspace that installs you, signed with your app’s own webhook secret. You get that secret once, on your app’s page in the dashboard. Deliveries are the same shape, signature and retry policy as a merchant’s own webhooks, with two additions: the platform topics, and scope gating.

Verifying a delivery

TypeScript
import crypto from 'node:crypto'

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))   // t=..., v1=...
  const expected = crypto.createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300       // 5 minutes
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}

// Express: app.post('/hooks', express.raw({ type: '*/*' }), (req, res) => { ... })
// Verify the RAW body. A parsed-and-reserialised body will not match.

Platform topics

Always on. An app cannot unsubscribe from being told it was removed or that its permissions changed.

TopicWhenDo
app/uninstalledThe merchant removed your app.Delete or anonymise that workspace’s data on your side. Stop any jobs. Tokens are already revoked.
app/scopes_updateThe granted set changed: a grant after requestScope(), a revocation, or re-consent after an approved widening.Read the new granted_scopes and enable or disable features accordingly.

Business topics

A payload is a read: payment.completed carries what GET /transactions/:id would. So each topic is only delivered to apps that declared and were granted its read scope. Declaring a topic without its scope is rejected when you save the config, rather than silently never arriving.

TopicsRequires
payment.completed, payment.refunded, payment.voided, payment.failed, order.fulfilled, order.cancelledtransactions:read
payment.disputed, dispute.created, dispute.updated, dispute.evidence_submitted, dispute.won, dispute.lostdisputes:read
payout.created, payout.updated, payout.paid, payout.failed, payout.returnedpayouts:read
subscription.created, subscription.activated, subscription.updated, subscription.cancelled, subscription.paused, subscription.resumed, subscription.renewed, subscription.payment_failed, subscription.trial_endingsubscriptions:read

payment.completed does not always mean the money arrived

Card processors approve at checkout. Debit & Payouts settles on the network afterwards. Both send payment.completed, and on Debit & Payouts they send it twice — once when the charge is accepted, and again when it settles. If your app hands out something awkward to take back — a licence key, a Discord role, a shipment — acting on the first one can give it away for a payment that never lands.

RailWhat arrivesAct on
NMI, StripeOne payment.completed, as soon as the charge is approved.That one. There is no second event — including for ACH, whose approvals are also pending_settlement.
Debit & Payoutspayment.completed with status: "pending_settlement", then again with status: "settled".The settled one, for anything irreversible.

Do not branch on status alone. NMI charges are created as pending_settlement as well — cards briefly, ACH until it clears — so “skip while pending” strands every NMI sale waiting for a second event that is never sent. The distinction is the processor, not the status: only Debit & Payouts settles asynchronously, and only it sends a second event.

TypeScript
// payment.completed
const tx = await paysio.get(`/transactions/${event.data.transaction_id}`)

// Both halves. Either one alone is a bug.
const willSettleLater = tx.status === 'pending_settlement' && tx.processorType === 'aptpay'
if (willSettleLater) return   // the settled event is coming; do nothing yet

grantAccess(tx)

processorType is on the transaction rather than the webhook payload, so fetch the transaction — which you likely need anyway for items. Reversible work (an email, a note, an analytics event) can safely run on the first event; only the irreversible half needs to wait.

Delivery

  • Each delivery carries the workspace it belongs to; one endpoint serves every install.
  • Answer 2xx within 10 seconds. Anything else is retried: 8 attempts over about 24 hours with exponential backoff and jitter.
  • Deliveries can arrive more than once and out of order. Key your handling on the event id.
  • Deliveries stop the moment you are uninstalled or suspended.
  • Sandbox events are delivered with mode: "test". Treat them as rehearsals.

The webhook payloads themselves are documented under Webhooks in the core reference; an app receives the same objects a merchant does.