Writing Events

Last updated about 2 months ago

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 Any j17 identifier — UUID v4/v5, humane code, singleton, or tagged UUID 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 422 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, webhook, integration.

The id must be a valid j17 identifier — a v4/v5 UUID, a humane code, a declared singleton, or a tagged UUID. See Identifiers for the full rule. Prior to 0.7.11 the actor id rule was UUID-only.

For named non-user actors (a webhook source, an external integration), declare a singleton and use it as the actor id:

{
  "actor": { "type": "webhook", "id": "twilio_webhook" }
}

This requires "singletons": ["twilio_webhook"] in your spec.

target (optional)

What was affected, if different from the aggregate.

{
  "target": {
    "type": "order",
    "id": "550e8400-e29b-41d4-a716-446655440002"
  }
}

The id follows the same rule as actor and aggregate keys — see Identifiers. 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
}

timestamp (optional, non-production only)

Override the event's recorded timestamp. By default j17 stamps each event with the current Unix time in seconds; supplying metadata.timestamp replaces that stamp.

{
  "actor": { "type": "user", "id": "..." },
  "timestamp": 1700000000
}

Rules:

  • Non-production only — accepted in staging, test, and any other environment that is not prod. In prod the request is rejected with 422 and error: "metadata.timestamp is only accepted in non-production environments". This prevents accidental clock drift between staging and prod when test fixtures leak into production code paths.
  • Format — must be a non-negative integer (Unix seconds). Non-integers, negatives, floats, and other shapes return 422 with error: "metadata.timestamp must be a non-negative integer (Unix seconds)".
  • Scope — applies to single-event writes (POST /:type/:id/:event_type) and batch writes (POST /:type/:id). In a batch, the timestamp from the request's top-level metadata is applied to every event in the batch.
  • For per-event timestamps or backdated bulk loads, use the admin import endpoints instead (POST .../import, POST .../import_jsonl, POST .../cold_start).

This is the same field the engine has always recorded under metadata.timestamp on stored events — it was just not previously settable from the write endpoint.

No other fields — unknown metadata is refused

The five fields above (actor, target, previous_length, skip_occ, timestamp) are the complete client-writable metadata surface. Anything else in metadata is rejected with 422 rather than silently dropped:

  • Platform-stamped fields (idempotency_key, implied_by, scheduled, geo, saga) return error: "metadata.<field> is platform-stamped and cannot be set on writes". These appear in stored events — the platform writes them — but cannot be supplied on the wire. In particular, metadata.idempotency_key is not a write channel: sending it returns a 422 pointing you at the X-Idempotency-Key header, which is the only way to request idempotent handling.
  • Unknown fields return error: "Unknown metadata field: <field>". This catches typos that would otherwise fail silently — a misspelled previous_length used to be an OCC guard that never engaged.

Idempotency

Use the X-Idempotency-Key request header to prevent duplicate writes. This is a header, not a metadata field. Idempotency is supported on both single-event writes (POST /:type/:id/:event_type) and batch writes (POST /:type/:id).

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 code, a small set of headers (x-request-id, x-error-id), and a SHA-256 hash of your request body. The cache lasts 24 hours (rolling, per key).

  • Retry with same key + same body: Returns the cached response — same status code, same body, with X-Idempotency-Replayed: true set. No duplicate event is written and no handlers re-run.
  • Retry with same key + different body: Returns 422 with error: "Idempotency key reused with different request body". This catches accidental key reuse bugs.
  • Failed requests (4xx/5xx): Not cached. The same key can be retried freely until a 2xx response is received.

The body hash uses deterministic JSON encoding (keys sorted alphabetically before hashing), so reordering fields in your JSON object does not register as a different body. Equivalent JSON shapes (e.g. {"a":1,"b":2} vs. {"b":2,"a":1}) hash the same.

Provenance

On a successful keyed write, the platform stamps the key into the stored event as metadata.idempotency_key. It is visible when reading events and is part of the event's stored bytes (and therefore its audit chain hash). The field is output-only — supplying it in write metadata is a 422 (see metadata above).

Layered protection

Response replay is backed by a second, independent check in the engine: at append time, each keyed write atomically claims its key. A duplicate that slips past the response cache — a response that failed to cache, or a retry racing the original request's completion — resolves in one of two ways:

  • The original already finished: the duplicate is acked with the original stream_id and X-Idempotency-Replayed: true, though the body may omit fields like implied_count that only the original response carried.
  • The original is still executing: the duplicate gets 409 with Retry-After: 1 and an error saying the key is being processed. Retry after the original completes and you receive its replayed response.

Either way, at most one append happens per key — truly simultaneous duplicates (a double-submitted form racing itself) cannot both write.

Ordering vs. concurrency control

The idempotency ledger is evaluated before OCC. A write carrying both a recognized key and a previous_length guard returns the original cached response — the guard is never re-compared. This is what makes a keyed, guarded create a safe retry unit: if your first previous_length: 0 write succeeded but the response was lost to a timeout, the retry gets the original 201 back (marked X-Idempotency-Replayed: true), not a 409 you would have to interpret.

A 409 on a keyed write therefore always means the conflict was real at execution time — the key was not in the ledger (never seen, expired, or the original attempt failed) and the guard did not hold. Since failed responses are not cached, retrying after the conflict is resolved re-executes normally.

Scoping

Idempotency keys are scoped to instance + environment. The same key can be reused independently in prod, staging, and test without colliding. Keys from one instance never collide with keys from another instance.

Within one instance and environment, the scope is the whole instance — not the stream. A key used on order/abc123 is the same key on user/def456: the second write matches the first as a replay, returns the first write's cached response, and writes no event. This is the opposite of what most people assume, and it fails quietly. Prefix keys with something that distinguishes the operation — the aggregate type and ID, as in the examples here — rather than reusing a bare request or job ID across different streams.

Generating keys

Any string from 1 to 255 printable ASCII characters (0x20–0x7E). 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.

Validation errors (422)

Condition Error
Empty string Idempotency key cannot be empty
Longer than 255 bytes Idempotency key exceeds 255 character limit
Non-printable / non-ASCII characters Idempotency key contains invalid characters (must be printable ASCII)
More than one X-Idempotency-Key header on a request Multiple X-Idempotency-Key headers not allowed

Failure semantics

If the idempotency store is unreachable when checking a key, the request fails open — it proceeds as if the key had not been seen, and the response is not cached for replay. This means a retry during an outage may write a duplicate event; clients that need strict at-most-once delivery should pair idempotency keys with OCC (previous_length).

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 - Any client that retries on network errors

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 Opaque event identifier; use as a cursor when listing events
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:

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

You've exceeded your instance's burst rate limit or your plan's event allocation. Check X-RateLimit-Scope (instance or ip) and X-RateLimit-Tier. See rate limits for details.

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.

Error lookup

When a write fails with a 5xx, the response includes an x-error-id header with a short opaque ID (e.g. err_a1b2c3d4e5f6g7h8). You can use this ID to retrieve full error context — the error type, the event that triggered it, and diagnostic details about what went wrong.

GET /_errors/:error_id

Authentication: API key (read scope). Errors are scoped to the instance and environment — a key for myapp prod cannot read errors from myapp staging.

curl https://myapp.j17.dev/_errors/err_a1b2c3d4e5f6g7h8 \
  -H "Authorization: Bearer $API_KEY"
{
  "ok": true,
  "error": {
    "error_id": "err_a1b2c3d4e5f6g7h8",
    "error_type": "ValueNotFound",
    "message": "Path $.data.quantity not found",
    "context": {
      "event_key": "order:abc123",
      "event_type": "was_placed",
      "operation": "set"
    },
    "request_id": "req_...",
    "occurred_at": "2026-05-27T12:34:56Z"
  }
}

Errors are retained for 24 hours from when they are stored. After that, the ID returns 404. Use error IDs for debugging, not as durable references.

See also

Can't find what you need? support@j17.app