Webhooks API
Manage webhook endpoints and receive real-time event notifications.
All endpoints require:
Authorization: Bearer <token>(JWT with admin role)X-Bulwark-Tenant: <tenant-id>headerX-Bulwark-App-Id: <app-id>header — Optional. Filters webhooks to a specific application.
Endpoints
List webhooks
GET /api/v1/webhooks
Returns all registered webhook endpoints for the tenant.
Response
[
{
"id": "wh_01j...",
"url": "https://myapp.com/webhooks/bulwark",
"description": "Production webhook",
"events": ["user.created", "user.login"],
"created_at": "2026-03-01T12:00:00Z"
}
]
Create webhook
POST /api/v1/webhooks
Request body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| url | string | Yes | HTTPS endpoint to receive events |
| events | string[] | Yes | Event types to subscribe to |
| description | string | No | Human-readable label |
{
"url": "https://myapp.com/webhooks/bulwark",
"events": ["user.created", "user.login", "user.deleted"],
"description": "Production webhook"
}
Response — 201 Created
{
"id": "wh_01j...",
"url": "https://myapp.com/webhooks/bulwark",
"description": "Production webhook",
"events": ["user.created", "user.login", "user.deleted"],
"secret": "whsec_a3f9...",
"created_at": "2026-03-20T10:00:00Z"
}
The secret field is returned only on creation. Store it immediately — it is hashed and cannot be retrieved again. Use it to verify incoming webhook signatures.
Delete webhook
DELETE /api/v1/webhooks/{id}
Response — 204 No Content
Events
| Event | Triggered when |
|-------|---------------|
| user.created | A new user account is registered |
| user.login | A user successfully authenticates |
| user.password_changed | A user changes their password |
| user.email_verified | A user verifies their email address |
| user.deleted | A user account is deleted |
Payload format
Every webhook delivery sends a POST request to your endpoint with a JSON body:
{
"event": "user.created",
"timestamp": "2026-03-20T10:05:00Z",
"data": {
"id": "usr_01j...",
"email": "[email protected]",
"display_name": "Jane Doe",
"email_verified": false,
"created_at": "2026-03-20T10:05:00Z"
}
}
The structure of data varies by event type and matches the corresponding user or session object.
Signature verification
Each delivery includes an X-Bulwark-Signature header containing an HMAC-SHA256 signature of the raw request body, computed using the webhook secret.
Algorithm: HMAC-SHA256(secret, body)
Header format: sha256=<hex-digest>
Verification examples
Node.js
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = createHmac("sha256", secret)
.update(body)
.digest("hex");
const expectedBuffer = Buffer.from(`sha256=${expected}`, "utf8");
const signatureBuffer = Buffer.from(signature, "utf8");
if (expectedBuffer.length !== signatureBuffer.length) return false;
return timingSafeEqual(expectedBuffer, signatureBuffer);
}
// In your route handler:
app.post("/webhooks/bulwark", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-bulwark-signature"] as string;
const isValid = verifyWebhook(req.body.toString(), signature, process.env.BULWARK_WEBHOOK_SECRET!);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString());
// handle event...
res.status(200).json({ received: true });
});
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func verifyWebhook(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := fmt.Sprintf("sha256=%s", hex.EncodeToString(mac.Sum(nil)))
return hmac.Equal([]byte(expected), []byte(signature))
}
Always use a constant-time comparison (timingSafeEqual / hmac.Equal) to prevent timing attacks.
Retry behavior
Failed deliveries (non-2xx response or timeout) are retried up to 5 times with exponential backoff: 30s, 2m, 10m, 30m, 2h. After all retries are exhausted the delivery is marked as failed and no further attempts are made.
Respond with 2xx as quickly as possible. Process events asynchronously if your handler does significant work.