Skip to main content

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>