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