API Endpoints
All endpoints require an X-API-Key header unless otherwise noted. Requests with a JSON body must include Content-Type: application/json.
Base URL: https://hapnd-api.lightestnight.workers.dev
Event Operations
Section titled “Event Operations”POST /events
Section titled “POST /events”Append a single event to an aggregate.
Request:
{ "aggregateId": "order_123", "aggregateType": "order", "eventType": "ItemAdded", "data": { "item": "Widget", "price": 25.0 }, "expectedVersion": 5, "correlationId": "req_abc123", "causationId": "evt_xyz789", "metadata": { "userId": "user_456" }}Only aggregateId, eventType, and data are required. All other fields are optional. If aggregateType is omitted, it’s inferred from the ID.
Response (201):
{ "eventId": "evt_abc123", "aggregateId": "order_123", "aggregateType": "order", "version": 6, "timestamp": "2026-03-15T18:30:00.000Z", "state": { "total": 50.0, "items": ["Widget", "Gadget"], "itemCount": 2 }}The state field is present only if a reducer is bound to the aggregate type.
POST /events/batch
Section titled “POST /events/batch”Append multiple events atomically to a single aggregate. All events succeed or none do.
Request:
{ "aggregateId": "order_123", "aggregateType": "order", "events": [ { "eventType": "ItemAdded", "data": { "item": "Widget", "price": 25.0 } }, { "eventType": "ItemAdded", "data": { "item": "Gadget", "price": 15.0 } } ], "expectedVersion": 5, "correlationId": "req_abc123", "causationId": "evt_xyz789", "metadata": { "userId": "user_456" }}Response (201):
{ "aggregateId": "order_123", "aggregateType": "order", "startVersion": 6, "endVersion": 7, "eventIds": ["evt_abc123", "evt_def456"], "timestamp": "2026-03-15T18:30:00.000Z", "state": { "total": 65.0, "items": ["Widget", "Gadget"], "itemCount": 2 }}GET /events/{aggregateId}
Section titled “GET /events/{aggregateId}”Retrieve events for an aggregate.
Response (200):
{ "events": [ { "eventId": "evt_abc123", "aggregateId": "order_123", "eventType": "OrderPlaced", "data": { "customerId": "cust_456" }, "version": 1, "timestamp": "2026-03-15T18:00:00.000Z" } ]}Reducer Operations
Section titled “Reducer Operations”POST /reducers/upload
Section titled “POST /reducers/upload”Upload reducer source code for compilation. Uses multipart/form-data.
Request:
curl -X POST https://hapnd-api.lightestnight.workers.dev/reducers/upload \ -H "X-API-Key: sk_live_your_api_key" \ -F "file=@reducer.zip"The zip must contain .cs source files with at least one IReducer<T> implementation.
Response (202):
{ "reducerId": "red_abc123", "status": "pending_compilation"}GET /reducers/{id}/status
Section titled “GET /reducers/{id}/status”Check compilation status and discovered implementations.
Response (200):
{ "id": "red_abc123", "status": "compiled", "kind": "reducer", "discovered": [ { "className": "MyReducers.OrderReducer", "stateTypeName": "MyReducers.OrderState", "kind": "reducer", "aggregateType": "order" } ], "createdAt": "2026-03-15T18:30:00.000Z", "compiledAt": "2026-03-15T18:30:05.000Z"}The discovered array lists all IReducer<T> implementations found in the compiled DLL. For multi-reducer DLLs, multiple entries appear with their resolved aggregateType. If compilation failed, the response includes compilationError instead of discovered.
PUT /aggregate-types/{type}
Section titled “PUT /aggregate-types/{type}”Bind a reducer to an aggregate type.
Request:
{ "reducerId": "red_abc123", "className": "MyReducers.OrderReducer"}className is optional. When omitted, the platform auto-resolves it from the discovered implementations by matching the aggregate type. In most cases, manual binding is not needed; deploying a reducer DLL auto-binds all discovered reducers to their aggregate types.
Response (200):
{ "aggregateType": "order", "reducerId": "red_abc123", "boundAt": "2026-03-15T18:30:00.000Z", "message": "Reducer bound. Aggregate state catchup in progress."}GET /aggregate-types/{type}
Section titled “GET /aggregate-types/{type}”Get the current reducer binding for an aggregate type.
Response (200):
{ "aggregateType": "order", "reducerId": "red_abc123", "boundAt": "2026-03-15T18:30:00.000Z"}GET /aggregate-types/{type}/{id}/state
Section titled “GET /aggregate-types/{type}/{id}/state”Query the current computed state of an aggregate.
Response (200):
{ "aggregateId": "order_123", "aggregateType": "order", "version": 6, "state": { "total": 50.0, "items": ["Widget", "Gadget"], "itemCount": 2 }, "lastModified": "2026-03-15T18:30:00.000Z"}Returns 404 with NO_REDUCER_BOUND if no reducer is bound, or NOT_FOUND if the aggregate doesn’t exist.
Projection Operations
Section titled “Projection Operations”POST /projections/upload
Section titled “POST /projections/upload”Upload projection source code for compilation. Uses multipart/form-data. Optionally include notification configuration.
Request:
curl -X POST https://hapnd-api.lightestnight.workers.dev/projections/upload \ -H "X-API-Key: sk_live_your_api_key" \ -F "file=@projection.zip" \ -F 'config={"notifications":{"webhook":{"url":"https://your-api.com/hooks/hapnd"}}}'The config field accepts either {"notifications": {...}} (wrapped) or the notification config directly at the top level. Both formats work.
Response (202):
{ "success": true, "uploadId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "projectionId": null, "status": "compiling", "message": "Projection uploaded successfully and queued for compilation", "notifications": { "webhook": { "enabled": true, "secret": "whsec_K7xN2bQ9mP4xR1sT8vW5yZ2a...", "isolated": false }, "sse": false }}The uploadId is a plain UUID used to track this specific deploy. The projectionId is null at upload time; the stable ID is assigned during compilation. Poll GET /projections/{uploadId}/status until status is "compiled" or type is "runtime" to retrieve the stable projectionId.
The notifications field is only present when notification config was provided in the upload. The secret is the active webhook secret; store this securely. The isolated field indicates whether this is a projection-specific secret or the shared tenant secret.
GET /projections/{id}/status
Section titled “GET /projections/{id}/status”Check projection compilation status.
Response (200):
{ "type": "compilation", "uploadId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "projectionId": "proj_stable_xyz789", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "compiled", "kind": "projection", "discovered": [ { "className": "MyProjections.SalesProjection", "stateTypeName": "MyProjections.SalesStats", "kind": "projection" } ], "createdAt": "2026-03-15T18:30:00.000Z", "compiledAt": "2026-03-15T18:30:05.000Z"}The type field is "compilation" while the upload is being processed, and "runtime" once the projection has been activated. The projectionId field is null until compilation completes; once populated, use it for all subsequent API calls (state queries, notifications, webhooks). The id field is deprecated; use uploadId instead.
Projection discovered entries don’t include aggregateType; projections define their own state key via ResolveKey.
GET /projections/{id}/state/{key}
Section titled “GET /projections/{id}/state/{key}”Query the current state of a projection by its state key. The state key is the value returned by the projection’s ResolveKey method.
Authentication: API key via X-API-Key header
Path Parameters:
id- The stable projection ID returned after compilation (e.g.proj_abc123). This is theprojectionIdfrom the status endpoint, not theuploadIdfrom the upload response.key- The state key fromResolveKey(e.g.customer-123). URL-encode if the key contains special characters.
200 OK, Projection is active and state exists for this key:
{ "projectionId": "proj_abc123", "stateKey": "customer-123", "state": { "totalOrders": 5, "totalSpend": 249.99 }, "updatedAt": "2026-04-20T14:30:00.000Z"}202 Accepted, Projection exists but is still catching up on historical events:
{ "projectionId": "proj_abc123", "status": "catching_up"}404 Not Found, Projection does not exist, or no state exists for the given key:
{ "error": "NOT_FOUND"}During catchup, the projection’s state is incomplete. The 202 response prevents consumers from reading partial state. Query again once catchup completes.
The state field contains the full projected state object, the same shape your projection’s Apply method returns.
State keys are defined by your projection’s ResolveKey method. For cross-aggregate projections, the key may differ from the source aggregate ID.
GET /projections/{id}/notifications
Section titled “GET /projections/{id}/notifications”Poll for projection state changes. Cursor-based pagination via afterSequence.
Path Parameters:
id- The stable projection ID (projectionId from the status endpoint)
Request:
curl "https://hapnd-api.lightestnight.workers.dev/projections/proj_abc/notifications?afterSequence=42" \ -H "X-API-Key: sk_live_your_api_key"Response (200):
{ "notifications": [ { "sequence": 43, "projectionId": "proj_abc", "aggregateId": "order_123", "aggregateType": "order", "state": { "totalRevenue": 1500.0, "orderCount": 15 }, "timestamp": "2026-03-15T18:30:00.000Z" } ]}POST /projections/{id}/webhook/rotate
Section titled “POST /projections/{id}/webhook/rotate”Rotate the webhook secret for an isolated projection. Only works for projections uploaded with isolateSecret: true.
Response (200):
{ "projectionId": "proj_abc123", "webhook": { "secret": "whsec_newSecret...", "isolated": true }}Returns 400 if the projection uses the shared tenant secret; use POST /webhooks/rotate instead.
Webhook Operations
Section titled “Webhook Operations”POST /webhooks/rotate
Section titled “POST /webhooks/rotate”Rotate the tenant-level webhook secret. Affects all projections that use the shared secret (those without isolateSecret: true).
Response (200):
{ "webhook": { "secret": "whsec_newSecret..." }}Streaming
Section titled “Streaming”GET /projections/{id}/stream
Section titled “GET /projections/{id}/stream”WebSocket upgrade for real-time projection state changes. Authenticate via X-API-Key header on the upgrade request.
Server messages:
State change:
{ "type": "state_changed", "projectionId": "proj_abc", "aggregateId": "order_123", "aggregateType": "order", "version": 6, "state": { "totalRevenue": 1500.0 }, "sequence": 43, "timestamp": "2026-03-15T18:30:00.000Z"}Keepalive:
{ "type": "ping"}Client messages:
Pong response:
{ "type": "pong"}Resume from a specific sequence by connecting with ?afterSequence=42 on the URL.
System
Section titled “System”GET /health
Section titled “GET /health”Health check. Does not require authentication.
Response (200):
{ "status": "ok"}GET /auth/whoami
Section titled “GET /auth/whoami”Validate an API key and return tenant information.
Response (200):
{ "tenantId": "tenant_abc123", "name": "My Company", "createdAt": "2026-01-15T10:00:00.000Z"}