Writing Events
This guide explains how to store events in EventSourcingDB using its HTTP API. At the end, you will have successfully written your first events.
Before You Begin
All examples on this page and the following ones assume that you have a running EventSourcingDB instance and a valid API token. If not, see Running EventSourcingDB.
Whenever a curl command contains <API_TOKEN>, replace it with your actual token. Otherwise, EventSourcingDB will reject the request due to failed authentication.
No further configuration is required – all examples work out of the box with the default setup.
The examples use the v2 API of EventSourcingDB. If you have worked with the v1 API before, Migrating to the v2 API explains what has changed.
Modeling an Event#
Before you can store an event, you first need to think about what the event should represent. This depends on your business domain, as the event's meaning is tightly coupled to it.
For example, if you're building an online shop, you might have an order submitted event. If you're developing a chess server, perhaps a knight moved event. If you're managing a library, a book acquired event. We'll use the library example in this guide because it's easy to follow, includes some interesting aspects, and might even feel familiar.
For now, we'll go with the book acquired event because acquiring a book logically happens at the beginning – otherwise, the library wouldn't have any books.
So, you already know that the type of your event should be book acquired. Since EventSourcingDB is based on the CloudEvents standard, event types must be specified as reverse domain names – for example, io.eventsourcingdb.library.book-acquired. For more details, see Event Types.
The CloudEvents standard requires a few additional fields:
- The source (the application that emits the event)
- The subject (this is essentially the object the events refer to)
- The actual data, containing the event-specific business payload
Let's assume that your application is intended to run under https://library.eventsourcingdb.io. Then the source would simply be this URL. EventSourcingDB accepts a source only if it is a URI as RFC 3986 defines it, as this URL is, and refuses any other as source-malformed. It stores the source exactly as you send it. For more details, see Sources.
Let's also assume that you assign a random ID to the newly acquired book, e.g. 42. The subject could then look like this: /books/42. For more details, see Subjects.
The data is a standard JSON object containing the book's details:
{
"title": "2001 – A Space Odyssey",
"author": "Arthur C. Clarke",
"isbn": "978-0756906788"
}
The data may hold any JSON values. EventSourcingDB keeps the value of every number in it exactly, with up to 1,000 digits, even where a 64-bit floating-point number could not hold it. It refuses a number with more digits, or one beyond the range of a 64-bit floating-point number, as data-malformed.
That gives us everything we need to construct the event:
{
"source": "https://library.eventsourcingdb.io",
"subject": "/books/42",
"type": "io.eventsourcingdb.library.book-acquired",
"data": {
"title": "2001 – A Space Odyssey",
"author": "Arthur C. Clarke",
"isbn": "978-0756906788"
}
}
Events vs. Event Candidates
Technically, events contain additional fields, such as the timestamp when the event was created and the version of the CloudEvents specification. However, these fields are automatically added by EventSourcingDB when the event is stored.
Internally, what you send to the database is called an event candidate. It's not an actual event yet – it's more like a proposal to become one. Only when the database accepts and stores the candidate does it turn into a real event.
Storing an Event#
To store the event, you send a POST request to EventSourcingDB's /api/v2/data/write-events route. The data in its path is the category of the route: it works with the events themselves. You can send the request from the command line using curl. Make sure to include the API token for authentication that was set when starting the database:
curl \
-i \
-X POST \
-H "authorization: Bearer <API_TOKEN>" \
-H "content-type: application/json" \
-d "{
\"events\": [
{
\"source\": \"https://library.eventsourcingdb.io\",
\"subject\": \"/books/42\",
\"type\": \"io.eventsourcingdb.library.book-acquired\",
\"data\": {
\"title\": \"2001 – A Space Odyssey\",
\"author\": \"Arthur C. Clarke\",
\"isbn\": \"978-0756906788\"
}
}
]
}" \
http://localhost:3000/api/v2/data/write-events
If Something Goes Wrong
If the server responds with 401 Unauthorized and the body unauthorized, the API token is missing or wrong. Check whether you replaced <API_TOKEN> in the example with your actual token.
Understanding the Response#
If everything worked as expected, the server will respond with HTTP status code 200 OK. The first line should look like this:
HTTP/1.1 200 OK
Among the headers, Content-Type says application/x-ndjson: the response is a stream of JSON items, one per line. You can ignore the other headers.
What's more interesting is the response payload. Each of its items is an envelope: its type says what the item is, and its data holds the item itself. For a write, the stream holds one event item for every event that EventSourcingDB has written, followed by an end item, which says that the stream is complete. Apart from time, which records when the event was written, and hash, which covers the time as well, it looks like this:
{
"type": "event",
"data": {
"specversion": "1.0",
"id": "0",
"source": "https://library.eventsourcingdb.io",
"subject": "/books/42",
"type": "io.eventsourcingdb.library.book-acquired",
"time": "2026-10-25T10:02:17.635402918Z",
"predecessorhash": "0000000000000000000000000000000000000000000000000000000000000000",
"datacontenttype": "application/json",
"data": {
"author": "Arthur C. Clarke",
"isbn": "978-0756906788",
"title": "2001 – A Space Odyssey"
},
"traceparent": null,
"tracestate": null,
"hash": "esdb:hash:v2:83610ab420e9342366432834e55c6d2c7dfc9a159a96868562a462fe6139ae01",
"signature": null
}
}
{
"type": "end",
"data": {}
}
The server sends each item as compact JSON on a single line; it is formatted here for readability. Reading Events explains this format, called NDJSON, in more detail.
The fields traceparent and tracestate carry the context of a distributed trace, if you send one along with an event. This event has none, so both are null. For details, see Integrating OpenTelemetry.
About `predecessorhash`, `hash`, and `signature`
EventSourcingDB adds cryptographic hashes and, optionally, signatures to each event to ensure integrity and authenticity:
- The
predecessorhashfield contains the hash of the previous event in the event store, or 64 zeros if this is the very first event. - The
hashfield is a cryptographic fingerprint of the current event. It is used to ensure data integrity. It starts with the prefixesdb:hash:v2:, which names the version of the hash, followed by 64 hexadecimal digits. - The
signaturefield indicates whether the event is cryptographically signed. For now, you can ignore this field – more details will follow later in the documentation.
For details on how hashes and signatures work in EventSourcingDB, see Verifying Event Signatures.
Writing Multiple Events at Once#
You may have noticed that the parameter in the curl example was called events (plural), not event (singular), and that the event was wrapped in an array.
That's because you can send multiple events in a single request. EventSourcingDB handles all events in a request as part of a transaction, guaranteeing atomicity – meaning either all events are written, or none. The response then holds one event item for each of them, followed by the end item.
Using Preconditions#
Sometimes you only want to store events under certain conditions – for example, only if no events for a subject exist yet, or only if no new events have been written since the last read, or only if a specific EventQL query evaluates to true.
To support this, EventSourcingDB lets you define preconditions along with the events. For more details, see Preconditions.
Transactions and Preconditions
EventSourcingDB will only store the submitted events if all specified preconditions are met. This preserves the atomicity of the operation.
Using isSubjectPristine#
To ensure that events are only written if the subject has no existing events, use the isSubjectPristine precondition. This is useful when an event is meant to introduce a subject, like the book-acquired event above – it must be the first event and must not appear multiple times for the same subject.
If you resend the request as shown above, another event will be added to subject /books/42. But if you include an isSubjectPristine precondition, EventSourcingDB will reject the write – which in this case is what you want:
curl \
-i \
-X POST \
-H "authorization: Bearer <API_TOKEN>" \
-H "content-type: application/json" \
-d "{
\"events\": [
{
\"source\": \"https://library.eventsourcingdb.io\",
\"subject\": \"/books/42\",
\"type\": \"io.eventsourcingdb.library.book-acquired\",
\"data\": {
\"title\": \"2001 – A Space Odyssey\",
\"author\": \"Arthur C. Clarke\",
\"isbn\": \"978-0756906788\"
}
}
],
\"preconditions\": [
{
\"type\": \"isSubjectPristine\",
\"payload\": {
\"subject\": \"/books/42\"
}
}
]
}" \
http://localhost:3000/api/v2/data/write-events
The status code is still 200 OK, because the request itself was valid:
HTTP/1.1 200 OK
The refusal is in the body instead, as an error item, followed by the end item:
{
"type": "error",
"data": {
"code": "https://docs.eventsourcingdb.io/errors/precondition-violated",
"description": "/preconditions/0: precondition isSubjectPristine does not hold",
"details": {
"precondition": 0
}
}
}
{
"type": "end",
"data": {}
}
The code names the error, here precondition-violated. The description says which precondition does not hold, and details names it by its place in the list of preconditions, counting from 0. Since the write is atomic, none of its events were written. For every error and how it arrives, see the overview of the errors.
Double-Check the Subject
Make sure you define the precondition for the correct subject. Technically, the precondition doesn't have to match the subject of the events being written, but that's typically what you want.
Using isSubjectPopulated#
The isSubjectPopulated precondition is the opposite of isSubjectPristine. It ensures that the subject already has at least one event – useful when you want to update or modify an existing subject.
For example, if you want to borrow a book, the book must have been acquired first. Using isSubjectPopulated prevents writing events to subjects that haven't been initialized yet:
curl \
-i \
-X POST \
-H "authorization: Bearer <API_TOKEN>" \
-H "content-type: application/json" \
-d "{
\"events\": [
{
\"source\": \"https://library.eventsourcingdb.io\",
\"subject\": \"/books/42\",
\"type\": \"io.eventsourcingdb.library.book-borrowed\",
\"data\": {
\"borrowedBy\": \"/readers/23\",
\"borrowedUntil\": \"2025-11-17\"
}
}
],
\"preconditions\": [
{
\"type\": \"isSubjectPopulated\",
\"payload\": {
\"subject\": \"/books/42\"
}
}
]
}" \
http://localhost:3000/api/v2/data/write-events
If the book hasn't been acquired yet (the subject is pristine), the write is refused with precondition-violated, just as above. Once you've written the book-acquired event first, the borrow operation will succeed.
Using isSubjectOnEventId#
The isSubjectOnEventId precondition also ensures that the subject already contains events – i.e., it is not empty.
In this case, you must specify both the subject and the ID of the most recent event. This lets you check that no new events have been added since you last read the subject – essentially enabling optimistic locking on a per-subject basis.
The following example tries to borrow a book from the library – which should only be allowed if the last known event was the acquisition of the book and no other events (like another borrow) have occurred since:
curl \
-i \
-X POST \
-H "authorization: Bearer <API_TOKEN>" \
-H "content-type: application/json" \
-d "{
\"events\": [
{
\"source\": \"https://library.eventsourcingdb.io\",
\"subject\": \"/books/42\",
\"type\": \"io.eventsourcingdb.library.book-borrowed\",
\"data\": {
\"borrowedBy\": \"/readers/23\",
\"borrowedUntil\": \"...\"
}
}
],
\"preconditions\": [
{
\"type\": \"isSubjectOnEventId\",
\"payload\": {
\"subject\": \"/books/42\",
\"eventId\": \"0\"
}
}
]
}" \
http://localhost:3000/api/v2/data/write-events
Run this immediately after acquiring the book, and it will succeed. If you repeat the request, it will be refused with precondition-violated – confirming that the optimistic locking worked as intended.
Using isEventQlQueryTrue#
Sometimes, you want to ensure that an event is only written if a more complex condition holds – for example, if no similar event has ever been recorded before. The isEventQlQueryTrue precondition lets you define such conditions using EventQL.
In the following example, we make sure that no other book with the same title already exists in the database. If a book titled 2001 – A Space Odyssey has already been acquired before, the write will be rejected:
curl \
-i \
-X POST \
-H "authorization: Bearer <API_TOKEN>" \
-H "content-type: application/json" \
-d "{
\"events\": [
{
\"source\": \"https://library.eventsourcingdb.io\",
\"subject\": \"/books/42\",
\"type\": \"io.eventsourcingdb.library.book-acquired\",
\"data\": {
\"title\": \"2001 – A Space Odyssey\",
\"author\": \"Arthur C. Clarke\",
\"isbn\": \"978-0756906788\"
}
}
],
\"preconditions\": [
{
\"type\": \"isEventQlQueryTrue\",
\"payload\": {
\"query\": \"FROM e IN events WHERE e.data.title == '2001 – A Space Odyssey' PROJECT INTO COUNT() == 0\"
}
}
]
}" \
http://localhost:3000/api/v2/data/write-events
As with the other preconditions, the write is refused with precondition-violated if the condition is not met.
Arbitrary Conditions
The isEventQlQueryTrue precondition gives you full control over whether events should be written, based on the current state of the database. You can use any valid EventQL query that returns a single boolean value.
Your Turn#
So far, we've only written events for a single book. But if you look at the book borrowed event, you'll notice that it references a reader with the ID /readers/23. That reader doesn't exist yet – so let's fix that.
As an exercise, try modeling and writing two new events:
-
A
reader appliedevent, indicating that someone has applied for a library card. This event should contain the reader's first and last name as data – for example:{ "firstName": "Jane", "lastName": "Doe" } -
A
reader acceptedevent, confirming that the reader has been approved and their library card has been issued. This event doesn't carry any domain-specific data – it should contain an empty object as payload:{}
Write the two events in two separate POST requests – one per event. Make sure to use the appropriate preconditions:
- Use
isSubjectPristinewhen writing thereader appliedevent to ensure that the subject doesn't exist yet. - Use
isSubjectOnEventIdwhen writing thereader acceptedevent to make sure it follows the application and that no other events have been written in the meantime.
You can use /readers/23 as the subject to match the book borrowed event above – or pick a different ID if you prefer.