Skip to content
Sigilbase

Sending events#

Events are appended to a stream with a single POST. Appends are strictly ordered: each event gets the next sequence number in its stream, with no gaps, and carries a hash chained to the event before it.

POST /api/v1/streams/{slug}/events
Authorization: Bearer sgb_example.xxxx
Content-Type: application/json

The request body must be a JSON object. Requests are authenticated with an API key sent as a bearer token; keys are created in the UI under API keys and can be scoped to specific streams.

Fields#

Field Required Rules
occurred_at yes RFC 3339 timestamp, for example 2026-07-09T09:15:00.000000Z. At most 24 hours in the future. This is your clock: when the thing happened in your system.
actor yes String, 1 to 255 characters. Who did it, in your own convention (user:42, service:billing).
action yes String, 1 to 255 characters. What happened (role.granted, invoice.voided).
resource no String, at most 255 characters. What it happened to.
payload yes JSON object, at most 32 KiB after canonicalisation. Integers only; no floats. See below.
pii no Boolean. Store this payload in the PII vault (Business/Enterprise): hashed as plaintext, envelope-encrypted at rest. A stream can be set to vault by default, in which case omitting pii behaves as pii: true and an explicit value always wins.
subject no String, at most 255 characters. Data-subject reference for a pii-flagged event; groups vault keys so erasure is one operation. Requires the event to resolve to pii: true (explicitly, or via the stream's vault-by-default).
idempotency_key no String, 1 to 255 characters, unique per stream. See retries.

Sigilbase adds received_at (its own clock), the sequence number, and the hashes. You cannot set those.

Payload rules#

Payloads are hashed, and the hash must be recomputable byte for byte by anyone, in any language, years later. That requires a canonical encoding, and Sigilbase uses RFC 8785 (JSON Canonicalization Scheme) with one restriction: numbers must be integers with absolute value at most 2^53 - 1. Floating-point numbers are rejected outright, because different JSON libraries format the same float differently, and a one-character difference produces a different hash. If you have monetary or fractional values, send integer minor units ("amount_pence": 1250) or a string ("ratio": "0.25"). Strings must be valid UTF-8. Empty objects and empty arrays are distinct.

The size limit applies to the canonical form: 32,768 bytes. Payloads are stored and returned in full; the limit exists to keep hashing, sealing, and export costs predictable.

Put references and hashes in payloads, not personal data. The ledger is append-only for life; your own systems are where personal data belongs, because they can honour an erasure request by actually erasing. {"patient": "patient:9f31", "document_hash": "sha256:c41a…"} proves what happened without storing who — the reference's meaning stays in your erasable store. If personal data lands in a payload by mistake, a stream owner can destroy it with hash-preserving redaction; if a workload must record personal data, flag it for the PII vault so later erasure is a key destruction rather than a crisis. Minimisation first, always.

Success responses#

A new append returns 201. The body is confirmation plus the material you need to verify the event later:

{
  "id": "0197c9a4-1b2f-7c3d-9e4a-5f6b7c8d9e0f",
  "sequence": 12,
  "entry_hash": "8c1f7a2b9d4e6f0a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a",
  "received_at": "2026-07-09T09:15:03.412876Z"
}

An idempotent replay returns 200 with the original event's body (see next section).

Retries and idempotency#

Networks fail after the server has committed. If you retry a POST without protection you may write the event twice. Set idempotency_key to a value that identifies the event in your system, and retries become safe: if the stream already holds an event with that key, the API appends nothing and returns 200 with the original event's id, sequence, and entry_hash.

curl -s -X POST https://app.sigilbase.io/api/v1/streams/admin-actions/events \
  -H "Authorization: Bearer sgb_example.xxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "occurred_at": "2026-07-09T09:15:00.000000Z",
    "actor": "user:42",
    "action": "role.granted",
    "payload": {"role": "admin"},
    "idempotency_key": "role-grant-8842"
  }'

Send the same request again (a retry after a timeout, say):

curl -s -X POST https://app.sigilbase.io/api/v1/streams/admin-actions/events \
  -H "Authorization: Bearer sgb_example.xxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "occurred_at": "2026-07-09T09:15:00.000000Z",
    "actor": "user:42",
    "action": "role.granted",
    "payload": {"role": "admin"},
    "idempotency_key": "role-grant-8842"
  }'

The second response is 200, not 201, and its body is identical to the first: same id, same sequence. No second event exists. Keys are compared per stream, and matching is by key alone, so a replay with different field values still returns the original event untouched.

Batching#

Up to 100 events can be appended in one request:

POST /api/v1/streams/{slug}/events:batch
curl -s -X POST "https://app.sigilbase.io/api/v1/streams/admin-actions/events:batch" \
  -H "Authorization: Bearer sgb_example.xxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {"occurred_at": "2026-07-09T09:16:00Z", "actor": "user:42", "action": "user.suspended", "resource": "user:77", "payload": {"reason": "fraud-review"}},
      {"occurred_at": "2026-07-09T09:16:01Z", "actor": "user:42", "action": "user.suspended", "resource": "user:78", "payload": {"reason": "fraud-review"}}
    ]
  }'

Each item follows the same rules as a single event, including its own idempotency_key. The batch is atomic: events are appended in array order inside one transaction, and one invalid item rejects the whole batch with nothing written. The response is {"events": [...]} with one confirmation object per item, 201 if anything new was appended, 200 if every item was a replay.

Rate limits#

Requests are limited to 60 per minute per API key across all authenticated endpoints. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. When the limit is exceeded the API returns 429 with a Retry-After header (seconds to wait) and X-RateLimit-Reset. Batching counts as one request, so high-volume producers should batch.

One addition on the free tier: once a Developer-plan tenant passes 3× its monthly event allowance, the append endpoints drop to 10 requests per minute for the rest of the month (reads keep the normal limit). This is a throttle, never a drop — a 429 with Retry-After is client-retryable, and every event is still accepted once you wait. Paid plans are never throttled by usage.

Errors#

Error responses are JSON. There are two shapes: {"message": "..."} for everything except validation, and the standard validation shape for 422:

{
  "message": "The actor field is required and must be at most 255 characters.",
  "errors": {
    "actor": ["The actor field is required and must be at most 255 characters."]
  }
}

The full catalogue:

Status When Body
401 No Authorization: Bearer header {"message": "Missing bearer token."}
401 Token does not look like sgb_... with a dot separator {"message": "Malformed API key."}
401 Key unknown, revoked, or wrong secret {"message": "Invalid or revoked API key."}
401 Key valid but past its expiry date {"message": "API key expired."}
403 Key is valid but not scoped to this stream {"message": "This API key is not scoped to stream [admin-actions]."}
403 Write to a reserved internal sigilbase-* stream {"error": "stream_reserved"}
404 Stream slug does not exist in your tenancy, or no event has that sequence {"message": "Record not found."}
410 Write to an archived stream (permanent, read-only) {"error": "stream_archived"}
422 Validation failed Validation shape above, keyed by field
423 Write to a paused stream {"error": "stream_paused"}
429 More than 60 requests in a minute on this key {"message": "Too Many Attempts."} plus Retry-After

Notable 422 messages, verbatim:

  • Request body not a JSON object: The request body must be a JSON object. (key body)
  • Batch envelope wrong: The events field must be an array of 1 to 100 event objects. (key events)
  • Batch item not an object: Each batch item must be a JSON object. (key events.{index})
  • Float in payload: Non-integer numbers cannot be canonicalised; payloads must use integers only. (key payload)
  • Payload over the limit: Canonical payload is 34063 bytes; the limit is 32768 bytes. (key payload, sizes vary)
  • Timestamp not RFC 3339: The occurred_at field must be an RFC 3339 timestamp, e.g. 2026-07-08T14:03:22.123456Z. (key occurred_at)
  • Timestamp too far ahead: The occurred_at field may not be more than 24 hours in the future. (key occurred_at)

Stream state is signalled with dedicated status codes and machine-readable bodies, not validation errors. A paused stream returns 423 with {"error": "stream_paused"}: reads, proofs, exports, and verification keep working, and resuming is instant. An archived stream returns 410 with {"error": "stream_archived"}: archiving is terminal and the stream stays readable forever. Writes to reserved sigilbase-* streams (your tenant's own admin audit trail) return 403 with {"error": "stream_reserved"}.