Webhooks
Subscribe to Permara events over HTTPS. Register an endpoint, store its signing secret (shown once), and Permara delivers signed JSON events with automatic retries.
Register an endpoint
permara webhooks endpoints add https://example.com/webhooks/permara --events 'payment.*'
Or via the SDK:
const endpoint = await permara.webhooks.createEndpoint({
url: 'https://example.com/webhooks/permara',
enabledEvents: ['payment.*', 'credential.revoked'],
})
// endpoint.secret is shown ONCE — store it now.
Subscriptions are event-type globs: an exact type (payment.delivered), a
family (payment.*), or everything (*).
Event envelope
Every delivery POSTs one event:
{
"event_id": "evt_…",
"event_type": "payment.delivered",
"event_version": 1,
"created_at": "2026-08-24T12:00:00.000Z",
"environment": "TEST",
"resource_id": "pay_…",
"data": { "payment_id": "pay_…", "status": "DELIVERED" }
}
Event types today: payment.created, payment.approval_required,
payment.approved, payment.held, payment.route_quoted,
payment.submitted, payment.processing, payment.delivered,
payment.failed, payment.refund_pending, payment.refunded,
credential.issued, credential.updated, credential.suspended,
credential.revoked, credential.expired.
Verify signatures
Each request carries safebank-signature: t=<unix>,v1=<hmac> (the header name is spelled as the API ships it) where the HMAC
is SHA-256 over "<t>.<raw body>" with your endpoint secret. Reject anything
that does not verify or whose timestamp is stale.
The SDK ships a static helper — WebhooksResource.verifySignature(rawBody, header, secret) — that parses the header, enforces a timestamp tolerance
(default 300 s), and does a constant-time compare. Copy-paste Express
receiver:
import express from 'express'
import { WebhooksResource } from '@permara/sdk'
const app = express()
// CRITICAL: verify against the RAW body bytes exactly as received.
// A JSON.parse → re-stringify round-trip will NOT reproduce the HMAC.
app.post(
'/webhooks/permara',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8')
const header = req.header('safebank-signature')
if (!WebhooksResource.verifySignature(rawBody, header, process.env.PERMARA_WEBHOOK_SECRET!)) {
return res.status(400).send('invalid signature')
}
// Parse ONLY after verifying.
const event = JSON.parse(rawBody)
// event_id is stable across retries — dedupe on it before side effects.
if (alreadyProcessed(event.event_id)) return res.sendStatus(200)
// Ack fast (Permara times out deliveries at 10s), process async.
res.sendStatus(200)
void handleEvent(event)
}
)
Prefer throwing? WebhooksResource.constructEvent(rawBody, header, secret)
verifies and returns the parsed envelope, throwing
WEBHOOK_SIGNATURE_INVALID on failure (the Stripe constructEvent
pattern), so a handler can never process an unsigned payload.
Idempotency and retries
Deliveries are at-least-once. The event_id (also sent as the
Permara-Event-Id header) is STABLE across retries — dedupe on it. Failed
deliveries retry with backoff (1m → 24h) before exhausting; inspect and
replay from the CLI:
permara webhooks deliveries --endpoint <id>
permara webhooks replay <deliveryId>