Integrations
Signed webhooks
Receive decision.created events, verify the exact payload, deduplicate retries, and process deliveries durably.
On this page
Configure your receiver
- Deploy a public HTTPS receiver on port 443. Localhost, private IPs, embedded URL credentials, and private Railway hostnames are rejected.
- Open Workspace → Webhooks. Save the URL and enable deliveries. Webhooks require Pro or Enterprise; the demo workspace is exempt.
- Store the signing secret in the receiver’s server environment as BASTION_WEBHOOK_SECRET.
- Generate a recorded event, then send a test from the dashboard. The test uses decision.created and an existing event.
curl -X PUT 'https://api.bastionsecurity.io/api/webhooks?siteId=YOUR_SITE_ID' \
-H "Authorization: Bearer $BASTION_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"url":"https://shop.example.com/webhooks/bastion","enabled":true}'GET /api/webhooks?siteId=… returns configuration, the owner-visible signing secret, and the latest 25 deliveries. PUT saves {url, enabled, rotateSecret?}. POST /api/webhooks/test?siteId=… sends a test when enabled and an event exists. Only the workspace owner can manage this integration.
Delivery headers & payload
Content-Type: application/json
User-Agent: Bastion-Webhooks/1.0
Bastion-Delivery: 00000000-0000-4000-8000-000000000001
Bastion-Signature: t=1791979200,v1=<64-character-hex-signature>{
"id": "00000000-0000-4000-8000-000000000001",
"type": "decision.created",
"createdAt": "2026-10-14T12:00:00.000Z",
"data": {
"id": "req_example",
"siteId": "site_example",
"accountId": "acc_internal_example",
"type": "checkout",
"riskScore": 48,
"riskLevel": "medium",
"classification": "suspicious",
"signals": {
"device_reuse": 20
},
"rulesTriggered": [
"device_reuse"
],
"deviceFingerprint": "fp_example",
"ipAddress": "203.0.113.10",
"userAgent": "Example browser",
"metadata": {
"action": "review",
"amount": 129,
"paymentHash": "pm_provider_identifier",
"decisionMs": 14.2,
"signalEvidence": [
{
"name": "device_reuse",
"score": 20,
"description": "Device shared across accounts."
}
]
},
"createdAt": "2026-10-14T12:00:00.000Z"
}
}Verify before parsing
The signature is HMAC-SHA256 of timestamp + "." + the exact raw request body bytes. Use the secret as its UTF-8 string, not as decoded hexadecimal. SDK helpers enforce a five-minute timestamp tolerance by default and compare signatures safely. JSON reserialization, middleware parsing, and whitespace changes invalidate the signature.
import express from 'express';
import pg from 'pg';
import { verifyWebhook } from '@bastionsecurity/sdk';
const secret = process.env.BASTION_WEBHOOK_SECRET;
if (!secret || !process.env.DATABASE_URL) {
throw new Error('BASTION_WEBHOOK_SECRET and DATABASE_URL are required');
}
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
// Register the raw-body route BEFORE any express.json() middleware.
app.post('/webhooks/bastion', express.raw({ type: 'application/json', limit: '256kb' }),
async (req, res) => {
try {
if (!Buffer.isBuffer(req.body) ||
!await verifyWebhook(secret, req.body, req.get('Bastion-Signature') || '')) {
res.sendStatus(401);
return;
}
let event;
try { event = JSON.parse(req.body.toString('utf8')); }
catch { res.sendStatus(400); return; }
const data = event?.data;
if (event?.type !== 'decision.created' ||
typeof event.id !== 'string' ||
event.id !== req.get('Bastion-Delivery') ||
!/^[0-9a-f-]{36}$/i.test(event.id) ||
!data || typeof data.id !== 'string' || !data.id ||
typeof data.siteId !== 'string' || !data.siteId ||
typeof data.accountId !== 'string' || !data.accountId ||
(data.metadata?.action !== undefined &&
!['allow', 'review', 'challenge', 'block'].includes(data.metadata.action)) ||
!Number.isFinite(data.riskScore) || data.riskScore < 0 || data.riskScore > 100) {
res.sendStatus(400);
return;
}
const inserted = await db.query(
'INSERT INTO bastion_webhook_inbox (delivery_id, payload) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING delivery_id',
[event.id, event],
);
res.sendStatus(inserted.rowCount ? 202 : 200);
} catch (error) {
console.error('Webhook acceptance failed:', error.message);
res.sendStatus(500); // No acknowledgement when durable storage fails.
}
});
app.use(express.json());
const server = app.listen(Number(process.env.PORT || 3000), process.env.HOST || '127.0.0.1',
() => console.log('Bastion webhook receiver listening.'));
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => server.close(() => void db.end()));
}
CREATE TABLE IF NOT EXISTS bastion_webhook_inbox (
delivery_id text PRIMARY KEY,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text
);
CREATE INDEX IF NOT EXISTS bastion_inbox_pending
ON bastion_webhook_inbox (next_attempt_at, received_at)
WHERE processed_at IS NULL;
-- event_id prevents a dashboard test/new delivery from processing
-- the same scored event twice. This is the example's durable business effect.
CREATE TABLE IF NOT EXISTS bastion_decision_audit (
event_id text PRIMARY KEY,
site_id text NOT NULL,
account_id text NOT NULL,
action text NOT NULL CHECK (action IN ('allow', 'review', 'challenge', 'block')),
risk_score double precision NOT NULL CHECK (risk_score BETWEEN 0 AND 100),
evidence jsonb NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT now()
);
This is the executable receiver source. The complete example includes the migration command, environment template, locked dependencies, and worker. Configure your own PostgreSQL database and webhook secret, run the migration, then run the receiver and worker as separate processes.
git clone https://github.com/bastionsecurityio/platform.git
cd platform/apps/web/examples/webhook-receiver
npm ci
cp .env.example .env
# Set DATABASE_URL and BASTION_WEBHOOK_SECRET in .env.
npm run migrate
npm start
# In a second terminal, from the same directory:
npm run workerProcess deliveries atomically
import pg from 'pg';
import { setTimeout as sleep } from 'node:timers/promises';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
let stopping = false;
for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => { stopping = true; });
// Multiple workers coordinate with PostgreSQL row locks. Recording the
// audit effect and marking the inbox row are one atomic transaction.
async function processNext() {
const connection = await db.connect();
let delivery;
try {
await connection.query('BEGIN');
const pending = await connection.query(
`SELECT delivery_id, payload FROM bastion_webhook_inbox
WHERE processed_at IS NULL AND next_attempt_at <= now()
ORDER BY received_at LIMIT 1 FOR UPDATE SKIP LOCKED`,
);
delivery = pending.rows[0];
if (!delivery) { await connection.query('COMMIT'); return false; }
const event = delivery.payload.data;
await connection.query(
`INSERT INTO bastion_decision_audit
(event_id, site_id, account_id, action, risk_score, evidence)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.siteId, event.accountId, event.metadata?.action || 'review',
event.riskScore, JSON.stringify(event.metadata?.signalEvidence || [])],
);
await connection.query(
'UPDATE bastion_webhook_inbox SET processed_at=now(), last_error=NULL WHERE delivery_id=$1',
[delivery.delivery_id],
);
await connection.query('COMMIT');
return true;
} catch (error) {
await connection.query('ROLLBACK');
if (delivery) {
await connection.query(
`UPDATE bastion_webhook_inbox SET attempts=attempts+1,
last_error=$2, next_attempt_at=now()+interval '30 seconds'
WHERE delivery_id=$1 AND processed_at IS NULL`,
[delivery.delivery_id, error.message.slice(0, 500)],
);
}
throw error;
} finally {
connection.release();
}
}
try {
while (!stopping) {
try { if (await processNext()) continue; }
catch (error) { console.error('Webhook processing failed:', error.message); }
await sleep(500);
}
} finally {
await db.end();
}
The worker records a decision audit in bastion_decision_audit and marks the inbox row processed in the same PostgreSQL transaction. Row locks support concurrent workers. event_id is unique, so a new dashboard test delivery for an existing decision does not repeat the audit effect. Failed processing remains pending with last_error and retries after 30 seconds.
The executable example implements decision auditing. Charging a card or fulfilling an order remains subject to your server authorization policy. If you add financial or other external side effects, use an independently idempotent operation and a transactional outbox; a webhook alone does not approve fulfillment.
Signature helpers in every SDK
import { verifyWebhook } from '@bastionsecurity/sdk';
const valid = await verifyWebhook(secret, rawBody, signature);Pass the framework’s raw bytes, the Bastion-Signature header, and the configured webhook secret. Reject invalid signatures before using any payload fields. Keep your receiver clock synchronized.
Retries, duplicates & rotation
| Behavior | Contract |
|---|---|
| Acknowledgement | Return 2xx after durable acceptance. |
| Attempts | At most 5; non-2xx, network failures, and timeouts are retried. |
| Timeout | 10 seconds per delivery request. |
| Backoff | Approximately 60, 120, 240, and 480 seconds, plus worker scheduling. |
| Redirects | Not followed; use the final public HTTPS URL. |
| Delivery order | Do not depend on order. Deduplicate and reconcile by event ID. |
| Configuration changes | Disabling or losing plan access cancels queued deliveries. |
Bastion-Delivery and payload id remain stable across retries. A dashboard test may wrap the same recorded data.id in a new delivery ID. Deduplicate delivery IDs for transport, and data.id for business actions. A fresh signature timestamp is generated per attempt.
Set rotateSecret: true through PUT or use dashboard rotation. Copy the new secret to your receiver promptly. There is no automatic dual-key grace period. Coordinate rotation with active deliveries; a receiver can temporarily attempt both securely stored secrets during a planned migration.