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. A telemetry key (sgt_) is not accepted here: it belongs to the OpenTelemetry log receiver, and this endpoint answers it with 403 saying so.
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, the material you need to verify the event later, and a signed receipt:
{
"id": "0197c9a4-1b2f-7c3d-9e4a-5f6b7c8d9e0f",
"sequence": 12,
"entry_hash": "8c1f7a2b9d4e6f0a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a",
"received_at": "2026-07-09T09:15:03.412876Z",
"receipt": {
"body": {
"v": "sigilbase-receipt/1",
"outcome": "accepted",
"accepted": {"sequence": 12, "entry_hash": "8c1f…", "request_digest": "sha256:4e07…"},
"request_digest": "sha256:4e07…",
"received_at": "2026-07-09T09:15:03.412876Z",
"seal_by": "2026-07-09T09:30:03.412876Z",
"key_id": "7a19…",
"signed_at": "2026-07-09T09:15:03.415102Z"
},
"signature": "base64…"
},
"receipt_pending": false
}
The receipt is Sigilbase's signed statement of what it decided about your request, made before the event is sealed: it promises the event is sealed by a signed checkpoint no later than seal_by, fifteen minutes after the event was received (a replay's receipt carries the original event's deadline). Keep it. It is signed with a receipt key, published at GET /api/v1/receipt-keys and never used for checkpoints. Every fetch of GET /api/v1/streams/{slug}/events/{sequence}/receipt returns the same bytes. If the receipt signer is briefly unavailable the event is still appended: the response carries "receipt": null, "receipt_pending": true, and the receipt is signed moments later and served by that endpoint. Receipts explains every field and how to check one with the open-source verifier.
An idempotent replay returns 200 with the original event's id, sequence, and entry_hash (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, with the first event's id, sequence, and entry_hash. No second event exists. Its receipt says "outcome": "replay": it carries this request's request_digest beside the original's in accepted.request_digest, and the two match exactly when the replay sent the same content.
Keys are compared per stream. By default (return_original), matching is by key alone, so a replay with different field values still returns the original event untouched, and its receipt shows the two digests differ. A stream owner can switch a stream to reject and record (Streams, Key conflicts). There, a key reused with different content is refused with 409:
{
"error": "commitment_conflict",
"message": "This idempotency key already belongs to an event with different content…",
"decision_seq": 4,
"accepted": {"sequence": 12, "entry_hash": "8c1f…"},
"receipt": {"body": {"outcome": "conflict", "decision_seq": 4, "…": "…"}, "signature": "base64…"},
"receipt_pending": false
}
Nothing is appended. The attempt is recorded as decision decision_seq in the stream's decision log, readable at GET /api/v1/streams/{slug}/decisions, and sealed and anchored like any stream, with the signed conflict receipt served at GET /api/v1/streams/{slug}/decisions/{decision_seq}/receipt. Same-content retries stay ordinary replays and are not recorded. Either way, which content first held a key is never in doubt.
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, and carries its own receipt. 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. On a stream that rejects and records key reuse, a batch with any conflicting item appends nothing and answers 409 with one record per conflict under conflicts, each with its index in the batch; a batch that repeats a key with different content in itself is refused with 422.
Rate limits#
Three limits apply, each answered with 429, a Retry-After header (whole seconds to wait) and a JSON body. Nothing from a refused request is stored, so a client that waits and retries loses nothing.
Events per second, per workspace. Each plan has an event budget: a steady rate and a burst, counted in events across every events-API key and every stream the workspace has. A single append costs one event, a batch costs its length, and an idempotent replay costs the same as the first attempt. Requests refused for another reason (validation, a paused or archived stream, a key out of scope) cost nothing. The budget refills continuously, so a producer sending at or under the rate is never refused, and one that sends in bursts is fine up to the burst. The OTLP receiver and connection receivers have their own per-key limits and do not draw on it.
| Plan | Steady rate | Burst |
|---|---|---|
| Developer | 10 events a second | 100 events |
| Team | 50 events a second | 500 events |
| Business | 200 events a second | 2,000 events |
| Enterprise | By contract (Business's figures unless agreed otherwise) |
Over the budget the append endpoints return 429 with {"error": "event_budget_exceeded", "message": "...", "retry_after": 3} and Retry-After. Batching is how to spend it well: a batch of 100 is one request and 100 events.
Requests per minute, per API key. 600 appends a minute per key, and separately 600 reads a minute per key, so a heavy reader never starves its own producer. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; over the limit the API returns 429 with Retry-After and X-RateLimit-Reset.
Unauthenticated attempts, per source address. Before authentication, every request is also subject to 60 unauthenticated attempts per minute per source address. Requests that fail authentication (401) and calls to the credential-free GET /keys endpoint spend this budget; successfully authenticated requests do not, so several keys behind one address each keep their full per-key quota. Exceeding it returns the same 429 with Retry-After, before the key is looked up.
One addition on the free tier: once a Developer-plan tenant passes 3x 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 |
The workspace's event budget cannot pay for the request; or more than 600 requests in a minute on this key; or more than 60 unauthenticated attempts in a minute from this address | {"error": "event_budget_exceeded", "message": "...", "retry_after": 3}, or {"message": "Too Many Attempts."}; always with 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"}.