Publishing Events
Events are the atomic unit of communication in Decentrl — signed, encrypted, and dual-delivered.
Formal spec: DCTRL-0005 — Event Encryption and Storage defines event envelopes, dual delivery, encrypted tags, and the forward secrecy trade-off.
All communication and state changes in Decentrl are represented as cryptographically signed, encrypted events. Applications function as event processors: they publish events, query event streams, and reduce them into local application state.
Anatomy of an Event
An event has a type and a data payload:
{
type: "chat.message",
data: {
chatId: "abc",
content: "Hello Bob"
}
}When published, the event gets an event ID (a random UUID unless you pass one), is wrapped in a signed envelope, encrypted, and dual-delivered — one copy to the recipient's mediator, one copy to the sender's own mediator. The event ID is signed inside the envelope; it's how every later step recognises the same event.
The recipient reads the event with its meta: senderDid (authenticated from the contract it arrived on), eventId (signed), and timestamp (the sender's claim, unix ms). That's why the payload carries no id, sender or timestamp: identity in data is whatever the sender wrote, so reducers take it from meta.
Event Flow: Alice Sends a Message
1. Alice publishes a chat.message event:
- Encrypt with the contract's key for today's epoch → send to Bob's mediator (TWO_WAY_PRIVATE)
- Encrypt with storage key → save on Alice's mediator with encrypted tags
2. Alice's local state updates immediately (optimistic update):
- messages["abc"] = [...messages["abc"], newMessage]
3. Bob's client syncs (poll or WebSocket push):
- Pick the key by the payload's contract ID and epoch → decrypt → verify signature
- Set meta.senderDid / meta.eventId to the authenticated sender and the signed event ID
- Re-encrypt with storage key → store (one row per sender + event ID; a repeat is DUPLICATE)
- Validate against the schema, then apply to local state once: messages["abc"] = [...messages["abc"], newMessage]Duplicates and Retries
An event can reach Bob more than once — Alice retried, an acknowledgement was lost, a WebSocket push raced a sync, two of Bob's tabs or devices processed it, or a mediator re-delivered an old ciphertext. Every copy carries the same signed event ID, so:
- Bob's mediator stores it once. Each saved event carries a
dedupe_key(a keyed hash of sender and event ID, like an encrypted tag), and a repeat comes backDUPLICATEwithout changing the stored row. - Bob's app applies it once. A
DUPLICATEis still handed to the app (another tab may have stored it), and the SDK skips any(sender, event ID)it has already processed — also after a reload, since the keys are saved with the state snapshot.
The same ID makes retries safe. client.publish returns the event ID, and a failed publish reports it in the error's details.eventId. Publish again with that ID and no second event is created:
try {
await client.publish('chat.message', message, { recipient: bobDid })
} catch (error) {
const { eventId } = (error as DecentrlSDKError).details as { eventId: string }
await client.publish('chat.message', message, { recipient: bobDid, eventId })
}A custom event ID must be 1–128 characters from A-Z a-z 0-9 . _ : -; an invalid one fails before anything is sent (INVALID_EVENT_ID).
Ephemeral Events
Some events don't need to be stored. Presence indicators, typing signals, and online status are transient — they matter right now but not later.
Events tagged with no tags (tags: []) are sent via TWO_WAY_PRIVATE for real-time delivery but are not stored on the sender's mediator. They exist only in transit and in the recipient's pending event queue — once acknowledged, they vanish.
chat.presence → tags: [] // ephemeral — not stored, just delivered
chat.message → tags: ["chat.abc"] // persistent — stored and queryableDefining Events with the SDK
The SDK lets you define your event schema declaratively with Zod:
import { defineDecentrlApp } from '@decentrl/sdk'
import { z } from 'zod'
const app = defineDecentrlApp({
events: {
'chat.message': {
schema: z.object({
chatId: z.string(),
content: z.string(),
}),
tags: ['chat.${chatId}'],
},
'chat.reaction': {
schema: z.object({
chatId: z.string(),
messageKey: z.string(),
emoji: z.string(),
}),
tags: ['chat.${chatId}'],
},
'chat.presence': {
schema: z.object({}), // who and when come from meta
tags: [], // empty tags = ephemeral
},
},
state: { /* ... reducers ... */ },
})Publishing is a single method call — the SDK handles signing, encryption, and dual-delivery:
const client = app.createClient({ mediatorDid: 'did:web:mediator.decentrl.io' })
const { eventId } = await client.publish('chat.message', {
chatId: 'abc',
content: 'Hello Bob',
})publish is typed by the app: the event type must be declared, and data must match its schema's input. The data is validated once, and the parsed output is what is sent and what your own reducers get.