Events and Webhooks¶
Storage events record changes to buckets and objects. They can be read from the management API or delivered to an HTTP endpoint as webhooks.
Events are about data. For "who changed a setting", see the Audit Log.
Event types¶
| Type | Emitted when |
|---|---|
bucket.created |
A bucket is created |
bucket.deleted |
A bucket is deleted |
object.created |
An object is written where none existed |
object.updated |
An existing object is overwritten |
object.deleted |
An object or version is deleted |
object.restored |
A previous version is restored as the current one |
multipart.completed |
A multipart upload is completed |
multipart.aborted |
A multipart upload is aborted |
Event payload¶
{
"id": "0198e3c1-6d2a-7b41-9f30-4c8ad2f9e611",
"type": "object.created",
"time": "2026-08-14T09:12:44.129Z",
"bucket": "uploads",
"object": "invoices/2026/03/inv-1.pdf",
"version_id": "0198e3c1-6d2a-7b41-9f30-4c8ad2f9e612",
"size": 184320,
"metadata": {}
}
object, version_id, and size are absent for bucket-level events.
What an event is guaranteed to do¶
A committed change always produces its event. The obligation to publish is
written into the catalog inside the same transaction that commits the change, so a
crash between the two loses neither: on restart the pending events are picked up and
published. An event's id is allocated at that moment, so an event republished after
a crash is recognisably the same event rather than a second one.
Two consequences worth planning around:
- Events are asynchronous. A background pass moves committed events into the
delivery queue about once a second, so an event can appear in
GET /api/v1/eventsand at a webhook shortly after the write that caused it returns — not during it. A write that returns200has already produced its event as far as durability is concerned; only the delivery lags. - Delivery is at-least-once, never exactly-once. See
Retries: key your receiver on the event
id.
Events for changes committed before upgrading to this release are not reconstructed. The journal starts empty, because replaying history to every subscriber would be worse than the gap it papered over.
Reading events¶
curl -G https://management.example.com/api/v1/events \
-H "Authorization: Bearer <your-management-token>" \
--data-urlencode "bucket=uploads" \
--data-urlencode "type=object.created" \
--data-urlencode "limit=100"
Filters: since, until, bucket, type, prefix, and limit. Pagination uses
after_time and after_id together, taken from the previous page's next_time and
next_id. Sending one without the other is a 400 INVALID_EVENT_CURSOR.
Webhooks¶
Creating one¶
{
"target_url": "https://hooks.example.com/record-store",
"event_types": ["object.created", "object.deleted"],
"bucket_filter": "uploads",
"object_prefix_filter": "invoices/",
"enabled": true
}
| Field | Required | Effect |
|---|---|---|
target_url |
yes | Where deliveries are posted |
event_types |
yes | Which events to deliver |
bucket_filter |
no | Restrict to one bucket |
object_prefix_filter |
no | Restrict to one key prefix |
enabled |
no | Defaults to true |
The response contains the subscription and its signing secret:
{
"subscription": { "id": "…", "target_url": "…", "enabled": true },
"signing_secret": "<shown once>"
}
The signing secret is shown once
Record Store stores it encrypted under the credential master key and never returns it again. Store it with your receiver's configuration at creation time. If it is lost, delete the webhook and create a new one.
Delivery request¶
Each delivery is a POST with the event as the JSON body:
| Header | Value |
|---|---|
content-type |
application/json |
x-record-store-event-id |
The event's unique ID |
x-record-store-event-type |
e.g. object.created |
x-record-store-event-time |
RFC 3339 timestamp |
x-record-store-signature |
sha256=<hex HMAC-SHA256 of the raw body> |
Redirects are not followed. A 3xx response counts as a failure.
Verifying the signature¶
Compute HMAC-SHA256 over the raw request body with the signing secret and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
import hmac
from hashlib import sha256
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
Verify before parsing, and use the exact bytes received — re-serializing the JSON changes the signature.
Retries¶
A delivery succeeds on any 2xx. Otherwise it is retried with exponential backoff:
roughly 2^attempt seconds plus a small deterministic jitter, up to
webhooks.maximum_attempts (default 6). After that the delivery is permanently
failed and is not retried.
At-least-once delivery is the guarantee, and it is a guarantee about the floor:
an event is never silently dropped, and it may arrive more than once. Make your
receiver idempotent — key on x-record-store-event-id, which is stable across
retries of the same event and across a restart that republished it.
Exactly-once delivery is not offered and is not achievable across an HTTP boundary: a receiver that processes a delivery and then fails to answer is indistinguishable from one that never received it, so the delivery is retried.
Delivery log¶
Each entry records the attempt number, HTTP status, timestamp, success flag, and a bounded error summary. Response bodies are never stored — an arbitrary remote body is not something to keep in the database.
Enabling, disabling, deleting¶
curl -X PUT https://management.example.com/api/v1/webhooks/<webhook-id>/status \
-H "Authorization: Bearer <your-management-token>" \
-H "Content-Type: application/json" \
-d '{"enabled":false}'
curl -X DELETE https://management.example.com/api/v1/webhooks/<webhook-id> \
-H "Authorization: Bearer <your-management-token>"
A disabled webhook drops its pending deliveries rather than queueing them for later.
Target restrictions¶
A webhook URL is supplied by an administrator and fetched by the server, which makes it a server-side request forgery risk. Record Store applies these checks on every delivery attempt, not only at creation:
- HTTPS only, unless
webhooks.allow_httpis on. - No credentials in the URL, and no fragment.
- The hostname is resolved and every resolved address must be public, unless
webhooks.allow_private_networksis on. - The connection goes to the addresses that were validated, so a DNS answer cannot change between the check and the request.
- Redirects are not followed.
[webhooks]
allow_http = false
allow_private_networks = false
request_timeout_seconds = 10
maximum_attempts = 6
poll_interval_seconds = 2
Turning either flag on lets an administrator aim deliveries at loopback and internal services. Do that only for development or a deliberately internal receiver, and treat webhook creation as a privileged operation when you do.
Receiver checklist¶
- Verify the signature before parsing.
- Respond 2xx quickly; queue the work rather than doing it inline. Slow responses hit
request_timeout_secondsand become retries. - Deduplicate on
x-record-store-event-id. - Do not assume ordering. Retries interleave with new deliveries; use
timeandversion_idif order matters.