Writing Events
POST events to /{aggregate_type}/{aggregate_id}/{event_type}. That's the fundamental operation in j17. Everything else -- batching, implications, projections -- builds on this.
URL structure
POST /{aggregate_type}/{aggregate_id}/{event_type}
| Component | Description | Example |
|---|---|---|
aggregate_type |
Defined in your spec | order, user, task |
aggregate_id |
UUIDv4 or humane code | 550e8400-e29b-41d4-a716-446655440000 |
event_type |
Event name from spec | was_placed, had_email_updated |
Request body
{
"data": {
"items": [
{ "sku": "WIDGET-1", "quantity": 2, "price": 29.99 }
],
"shipping_address": {
"street": "123 Main St",
"city": "Austin",
"zip": "78701"
}
},
"metadata": {
"actor": {
"type": "user",
"id": "550e8400-e29b-41d4-a716-446655440001"
},
"previous_length": 0
}
}
data (required)
The event payload. Validated against the JSON Schema in your spec.
{
"data": {
"email": "alice@example.com",
"name": "Alice Smith"
}
}
Validation failures return 400 with details:
{
"ok": false,
"error": "Event data failed schema validation",
"path": "data.email"
}
metadata (required)
actor (required)
Who performed the action.
{
"actor": {
"type": "user",
"id": "550e8400-e29b-41d4-a716-446655440001"
}
}
The type must match an entry in your spec's agent_types. Common types: user, admin, system. The id must be a valid UUIDv4.
target (optional)
What was affected, if different from the aggregate.
{
"target": {
"type": "order",
"id": "550e8400-e29b-41d4-a716-446655440002"
}
}
Useful for cross-aggregate queries. "Show me all events targeting this order."
previous_length (optional)
For optimistic concurrency control. The expected number of events in the aggregate before this write.
{
"previous_length": 5
}
If the aggregate has a different length (concurrent write), j17 returns 409:
{
"ok": false,
"error": "Concurrent write detected. Stream has 6 events, expected 5."
}
Retry with the new length or handle the conflict.
skip_occ (optional)
For append-only patterns where conflicts don't matter. Requires allow_skip_occ: true in the event's spec definition.
{
"skip_occ": true
}
Idempotency
Use the X-Idempotency-Key request header to prevent duplicate writes. This is a header, not a metadata field.
curl -X POST https://myapp.j17.dev/order/abc123/was_placed \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: order-abc123-was_placed-20240115" \
-d '{"data": {...}, "metadata": {"actor": {...}}}'
How it works
On success (2xx), j17 caches the response body, status, and a hash of your request body. The cache lasts 24 hours.
- Retry with same key + same body: Returns the cached response with
X-Idempotency-Replayed: trueheader. No duplicate event written. - Retry with same key + different body: Returns 422 error. This catches accidental key reuse bugs.
- Failed requests (4xx/5xx): Not cached. You can retry with the same key.
Generating keys
Any string from 1-255 printable ASCII characters. What matters is that keys are unique per distinct operation.
Good patterns:
- {operation}-{entity_id}-{timestamp} -- order-placement-abc123-1705312800
- {user_id}-{action}-{nonce} -- user-789-checkout-a1b2c3d4
- UUID v4 -- 550e8400-e29b-41d4-a716-446655440000
The idempotency key represents the intent, not the data. If a user clicks "Submit Order" twice, both clicks should use the same key. But if they add an item and click again, that's a new operation with a new key.
When to use idempotency keys
Always use them for: - Payment processing - Order placement - Any operation with real-world side effects (sending emails, charging cards) - Batch operations
Not needed for: - GET requests (inherently idempotent) - Operations already protected by your own deduplication logic
Response
Success returns 201 Created:
{
"ok": true,
"stream_id": "1234567890123-0",
"implied_count": 2
}
| Field | Description |
|---|---|
stream_id |
Redis stream ID of the written event |
implied_count |
Number of implied events triggered (omitted if 0) |
Example: Complete flow
Creating an order:
# 1. Place the order
curl -X POST https://myapp.j17.dev/order/ord_123/was_placed \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: order-ord_123-was_placed-20240115" \
-d '{
"data": {
"customer_id": "550e8400-e29b-41d4-a716-446655440003",
"items": [
{ "sku": "WIDGET-1", "name": "Widget", "price": 29.99, "quantity": 2 }
]
},
"metadata": {
"actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440003" }
}
}'
# Response: {"ok": true, "stream_id": "...", "implied_count": 0}
# 2. Add an item (with OCC)
curl -X POST https://myapp.j17.dev/order/ord_123/had_item_added \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": {
"sku": "GADGET-2",
"name": "Gadget",
"price": 19.99,
"quantity": 1
},
"metadata": {
"actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440003" },
"previous_length": 1
}
}'
# Response: {"ok": true, "stream_id": "..."}
# 3. Mark as paid
curl -X POST https://myapp.j17.dev/order/ord_123/was_paid \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": {
"payment_id": "pay_789",
"amount": 79.97
},
"metadata": {
"actor": { "type": "system", "id": "550e8400-e29b-41d4-a716-446655440004" },
"previous_length": 2
}
}'
# Response: {"ok": true, "stream_id": "..."}
Aggregate IDs
Three formats accepted:
UUIDv4 (recommended)
Standard UUIDs:
550e8400-e29b-41d4-a716-446655440000
Use for most entities. Generate with any UUID library.
Humane codes
Human-readable identifiers:
ABC123XYZ
9 characters, Crockford base32. Good for: - Booking references - Promo codes - Support tickets - Anything customers type
global
Singleton aggregates:
curl https://myapp.j17.dev/config/global
One per aggregate type. Use for app-wide settings, feature flags.
Error handling
400 Bad Request
Validation error. Schema mismatch, missing actor, invalid aggregate ID.
404 Not Found
{
"ok": false,
"error": "Event type 'was_placed' not found in spec for aggregate 'order'"
}
Your spec doesn't define this event type. Check for typos or update your spec.
409 Conflict
OCC check failed. Retry with the current length.
413 Request Entity Too Large
Event data exceeds size limit.
422 Unprocessable Entity
Idempotency key reused with a different request body.
429 Rate Limited
Slow down. Rate limits: 500/min per API key, 2,000/min per IP.
Retry strategies
For 5xx errors or network failures:
async function writeEventWithRetry(path, data, options = {}) {
const idempotencyKey = options.idempotencyKey || crypto.randomUUID();
const maxRetries = options.maxRetries || 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(`https://myapp.j17.dev${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(data),
});
if (response.ok) return response.json();
if (response.status === 422) throw new Error('Idempotency key mismatch');
if (response.status < 500) throw new Error(`Client error: ${response.status}`);
// 5xx: retry
} catch (e) {
if (attempt === maxRetries - 1) throw e;
await sleep(Math.pow(2, attempt) * 100);
}
}
}
Always use idempotency keys when retrying to avoid duplicates.
See also
- Reading aggregates - GET to query state
- Batch operations - Atomic multi-event writes
- Atomicity concepts - OCC and consistency