Skip to content

Notifications

When a projection produces new state, Hapnd can push that change to your infrastructure. Notifications fire on projection state changes, not raw events — you receive the computed state, ready to use.

Three delivery mechanisms are available. Choose based on your use case, or combine them.

Outbound HTTP POST to your endpoint with HMAC-SHA256 signatures for verification. Best for server-to-server integration where you want Hapnd to push to you reliably.

Webhook configuration is provided when you upload a projection:

Terminal window
curl -X POST https://hapnd-api.lightestnight.workers.dev/projections/upload \
-H "X-API-Key: your_key" \
-F 'config={"notifications":{"webhook":{"url":"https://your-api.com/hooks/hapnd"}}}'

Optional fields:

{
"notifications": {
"webhook": {
"url": "https://your-api.com/hooks/hapnd",
"isolateSecret": true,
"headers": { "X-Custom-Header": "value" }
}
}
}

isolateSecret gives this projection its own webhook secret instead of sharing the tenant-level default. Custom headers are forwarded with each delivery (max 10 headers).

{
"type": "projection.state_changed",
"sequence": 1,
"projectionId": "proj_abc123",
"aggregateId": "order_123",
"aggregateType": "order",
"version": 5,
"state": {
"total": 125.00,
"items": ["Widget", "Gadget"],
"itemCount": 2
},
"triggeredBy": "evt_xyz789",
"timestamp": "2026-01-09T12:00:00Z"
}

state contains the full computed projection state — the output of your Apply method.

HeaderDescription
Content-TypeAlways application/json
X-Hapnd-Signaturesha256=<hex-encoded HMAC-SHA256 of the request body>
X-Hapnd-Delivery-Id{projectionId}-{sequence} — unique per delivery
X-Hapnd-AttemptAttempt number (1–5)

Plus any custom headers you configured.

Every webhook includes an HMAC-SHA256 signature in the X-Hapnd-Signature header. The signature is hex-encoded and prefixed with sha256=. Verify it before processing the payload.

C#:

var header = request.Headers["X-Hapnd-Signature"].ToString();
var payload = await new StreamReader(request.Body).ReadToEndAsync();
// Strip the "sha256=" prefix
var receivedSignature = header.Replace("sha256=", "");
// Compute HMAC-SHA256 as hex
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var computed = Convert.ToHexString(hash).ToLowerInvariant();
if (receivedSignature != computed)
{
return Results.Unauthorized();
}

TypeScript:

const header = request.headers.get("X-Hapnd-Signature")!;
const payload = await request.text();
const receivedSignature = header.replace("sha256=", "");
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(webhookSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signatureBuffer = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const computed = Array.from(new Uint8Array(signatureBuffer))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
if (receivedSignature !== computed) {
return new Response("Unauthorized", { status: 401 });
}

Hapnd generates webhook secrets automatically using the whsec_ prefix convention. Secrets are managed at two levels:

  • Tenant-level — a shared secret used by all projections by default
  • Per-projection — an isolated secret for projections that need independent rotation

The active secret is returned in the upload response when you configure a webhook:

{
"success": true,
"projectionId": "proj_abc123",
"status": "compiling",
"notifications": {
"webhook": {
"enabled": true,
"secret": "whsec_K7xN2bQ9mP4xR1sT8vW5yZ2a...",
"isolated": false
}
}
}

The isolated field tells you whether this is a projection-specific secret (true) or the tenant-wide shared secret (false).

Rotate secrets via the API:

Terminal window
# Rotate tenant-level secret (affects all projections using the shared secret)
curl -X POST https://hapnd-api.lightestnight.workers.dev/webhooks/rotate \
-H "X-API-Key: your_key"
# Rotate an isolated projection secret
curl -X POST https://hapnd-api.lightestnight.workers.dev/projections/proj_abc/webhook/rotate \
-H "X-API-Key: your_key"

Failed deliveries are retried up to 5 times with exponential backoff. After all retries are exhausted, the delivery is sent to a dead-letter queue for investigation. The notification log is the source of truth — you can always replay missed deliveries by polling /projections/{id}/notifications with the last sequence you successfully processed.

Webhook delivery times out after 10 seconds. Return a 2xx status code to acknowledge receipt.

Real-time push via persistent WebSocket connections. Best for dashboards, live UIs, and reactive workflows where latency matters.

var subscription = hapnd.Subscriptions()
.OnStateChanged<OrderState>(async (update, ct) =>
{
Console.WriteLine($"Order {update.AggregateId} total: {update.State.Total}");
})
.OnError(async (error, ct) =>
{
error.Action.Reconnect();
})
.Subscribe();
// Later:
await subscription.DisposeAsync();
const subscription = hapnd.subscribe({
projections: [
{
id: "proj_orders",
onUpdate: async (update) => {
console.log(`Order ${update.aggregateId} total: ${update.state.total}`);
},
},
],
onError: async (error) => {
error.action.reconnect();
},
});
// Later:
await subscription.close();

Each projection gets its own WebSocket connection with automatic reconnection and sequence tracking. See the .NET SDK and TypeScript SDK docs for full details.

Cursor-based polling for environments where WebSockets aren’t practical or you prefer pull-based consumption.

Terminal window
# First request — get latest notifications
curl https://hapnd-api.lightestnight.workers.dev/projections/proj_abc/notifications \
-H "X-API-Key: your_key"
# Subsequent requests — pass the last sequence to get only new notifications
curl "https://hapnd-api.lightestnight.workers.dev/projections/proj_abc/notifications?afterSequence=42" \
-H "X-API-Key: your_key"

The response includes an array of notifications, each with a sequence number. Pass the highest sequence as afterSequence on your next request to get only new changes.

MechanismBest forTrade-offs
WebhooksServer-to-server, reliable deliveryHapnd manages retries and DLQ; you provide an endpoint
WebSocketReal-time dashboards, reactive workflowsRequires persistent connection; SDK handles reconnection
PollingSimple integrations, batch processingYou control the pace; higher latency than push

You can use multiple mechanisms for the same projection. For example, WebSocket for your dashboard and webhooks for your billing system.

Notification logs are retained for 7 days. After that, they’re archived to R2 storage and removed from the active log. The daily cleanup runs at 3am UTC.

If you need to reprocess notifications older than 7 days, the projection can be re-activated to replay from historical events.