Skip to content

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/events and at a webhook shortly after the write that caused it returns — not during it. A write that returns 200 has 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

record-store webhook create ./webhook.json \
  --endpoint https://management.example.com
{
  "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

record-store webhook deliveries --limit 50 \
  --endpoint https://management.example.com

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_http is on.
  • No credentials in the URL, and no fragment.
  • The hostname is resolved and every resolved address must be public, unless webhooks.allow_private_networks is 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_seconds and become retries.
  • Deduplicate on x-record-store-event-id.
  • Do not assume ordering. Retries interleave with new deliveries; use time and version_id if order matters.