# Get notified when objects change

> Send a signed HTTPS POST to your own address each time an object in a bucket is created or deleted, and check each message's signature.

Source: https://www.coritan.com/docs/object-storage/event-notifications/

In the dashboard:

- /dashboard/storage/…/buckets/…/notifications: https://www.coritan.com/dashboard/storage

An *event notification rule* tells us to send a message to your own HTTPS address each time an object in a bucket is created or deleted. Use one to build thumbnails when a photo arrives, to update a search index when a file goes, or to keep your own records in step with a bucket. Each message is an S3 event record in JSON, signed with a secret only you and we know.

You manage rules in the bucket's **Notifications** section or with the API. S3's own `PutBucketNotificationConfiguration` call is not supported: S3 clients cannot change a bucket's rules.

## Before you begin

- Sign in to the [dashboard](https://www.coritan.com/dashboard/storage) and open the service. The service must be `active` to add, change or test a rule.
- Have an address that accepts a `POST`, such as `https://example.com/hooks/storage`. It must use HTTPS, be on the public internet and be outside Coritan ([Address rules](#address-rules)).
- Make sure the address answers with a `2xx` status within 10 seconds. Do the slow work after you answer.

## Add a rule

1. In the dashboard, go to [**Object Storage**](https://www.coritan.com/dashboard/storage), open the service, then the **Buckets** tab. Open the bucket, then **Notifications**.
2. Select **Add rule…**.
3. In **Address**, enter the address to send events to, starting with `https://`.
4. Under **Events**, tick **Object created**, **Object deleted** or both. [Events](#events) lists what each covers.
5. To send events for some keys only, fill in **Prefix**, **Suffix** or both. For example, **Prefix** `photos/` and **Suffix** `.jpg` covers `photos/2026/beach.jpg` and leaves out `photos/notes.txt`. Both are matched exactly, capitals included. Leave both empty to cover every key.
6. Optionally, enter a **Description**, such as `Thumbnail builder`, to tell rules apart in the list.
7. Select **Add rule**.

The dialog warns you before you save when the rule overlaps another one ([Overlapping rules](#overlapping-rules)).

## Result

A dialog shows the rule's *signing secret*. Copy it and store it where your receiver can read it, then select **I have saved it**. We show the secret only this once. If you lose it, rotate it to get a new one ([Rotate the signing secret](#rotate-the-signing-secret)).

The rule appears on the **Event notifications** card, and it covers changes made from that moment on. Changes made before you added it are not sent. The card lists each rule with:

**Rule**
: The description, or `Rule` and its number, with the address under it.

**Events**
: **Object created**, **Object deleted** or both.

**Keys**
: `All keys`, or the prefix and suffix a key must have.

**Status**
: `Active`; `Failing` when the latest delivery failed and none has worked since; `Paused` when you or we paused the rule.

**Last delivery**
: When the latest delivery worked or failed, and how many events are waiting.

To check the rule end to end, open its menu and select **Send test event**. A message tells you what your address answered, and the test appears in the **Delivery log**.

## Events {#events}

| Event | What sends it | `eventName` |
| --- | --- | --- |
| **Object created** | An upload (`PutObject`), a copy (`CopyObject`), a browser form upload (`PostObject`) | `ObjectCreated:Put` |
| **Object created** | A finished multipart upload (`CompleteMultipartUpload`) | `ObjectCreated:CompleteMultipartUpload` |
| **Object deleted** | A delete (`DeleteObject`), each key of a batch delete (`DeleteObjects`) | `ObjectRemoved:Delete` |
| **Object deleted** | A delete in a bucket with versioning on that leaves a delete marker | `ObjectRemoved:DeleteMarkerCreated` |
| **Object deleted** | A lifecycle rule of the bucket removing an object | `LifecycleExpiration:Delete` |

A few details:

- A copy and a browser form upload arrive as `ObjectCreated:Put`, as an upload does.
- Writing an object again sends `ObjectCreated:Put` again, even with the same content.
- In a bucket with versioning on, each new version is an `ObjectCreated:Put` with its `versionId`. Deleting one version for good is `ObjectRemoved:Delete` with that version's `versionId`.
- A change that neither creates nor deletes an object, such as adding tags to it, sends nothing.
- Changes made from the dashboard and with any access key are all sent.

We look for changes once a minute and send new events once a minute, so an event reaches your address a minute or two after the change.

## Overlapping rules {#overlapping-rules}

Two rules on one bucket *overlap* when they share an event and one key could match both. That is the case when one rule's prefix starts with the other's and one rule's suffix ends with the other's. An empty prefix overlaps every prefix, and an empty suffix every suffix.

| Rule A | Rule B | Overlap |
| --- | --- | --- |
| **Prefix** `photos/` | **Prefix** `photos/2026/` | Yes: `photos/2026/a.jpg` matches both. |
| **Prefix** `photos/`, **Suffix** `.jpg` | **Prefix** `photos/`, **Suffix** `.png` | No: a key cannot end in both. |
| **Prefix** `photos/` | **Prefix** `videos/` | No. |
| **Object created** on every key | **Object deleted** on every key | No: they share no event. |

We refuse a rule that overlaps another with `This rule overlaps rule <id>`, and the dialog shows the same warning before you save. To send one change to two addresses, have one receiver pass it on. To change what a rule covers, edit it instead of adding a second one.

## What we send {#what-we-send}

Each delivery is one `POST` to the rule's address, with one event in it:

```http
POST /hooks/storage HTTP/1.1
Content-Type: application/json
User-Agent: Coritan-Object-Storage-Events/1.0
X-Coritan-Event: ObjectCreated:Put
X-Coritan-Delivery: 9100
X-Coritan-Signature: t=1791633125,v1=ede87cf763a0dab52279c729ce7348346f503f1d1e88eafefc165a2bff5258e8
```

```json
{
  "Records": [
    {
      "awsRegion": "fra",
      "eventName": "ObjectCreated:Put",
      "eventSource": "coritan:s3",
      "eventTime": "2026-10-10T11:52:03.456Z",
      "eventVersion": "2.1",
      "s3": {
        "bucket": { "name": "u7-assets" },
        "configurationId": "31",
        "object": {
          "eTag": "9b2cf535f27731c974343645a3985328",
          "key": "photos/red+flower.jpg",
          "sequencer": "18DD28D5536B2000",
          "size": 48213
        },
        "s3SchemaVersion": "1.0"
      }
    }
  ]
}
```

The record has the shape of an S3 event record, so code written for S3 events reads it:

`key`
: The object's key, URL-encoded as S3 encodes it: a space is `+`. Decode it before you use it (`photos/red flower.jpg` here).

`size` and `eTag`
: Sent with created events. A multipart upload's `eTag` ends in `-` and the number of parts.

`versionId`
: Sent when the bucket has versioning on.

`sequencer`
: Compare two events for the same key as strings: the larger one is the later change. Deliveries can arrive out of order.

`configurationId`
: The rule's ID.

`awsRegion`
: The code of the bucket's region.

`X-Coritan-Delivery` is the delivery's number, the same on every attempt. If your address took a message but answered too late, we send it again, so use the number to skip one you have handled.

**Send test event** sends this body instead:

```json
{"Bucket":"u7-assets","ConfigurationId":"31","Event":"s3:TestEvent","Service":"Coritan Object Storage","Time":"2026-10-10T11:52:03.456Z"}
```

## Check the signature {#check-the-signature}

`X-Coritan-Signature` holds `t=<unix time>,v1=<signature>`. The signature is the hex HMAC-SHA256 of `<t>.<body>`, keyed with the rule's signing secret, where `<body>` is the request body exactly as it arrived. We sign each attempt when we send it.

To check a message:

1. Read the raw body before any JSON parser changes it.
2. Compute the HMAC of `t`, a full stop, and the body, with the signing secret as the key.
3. Compare it with `v1` in constant time, and refuse the message when they differ.
4. Refuse a `t` more than five minutes from your clock, so an old message cannot be sent to you again.

In Python:

```python title="verify.py"
import hashlib
import hmac
import time


def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())
```

In Node.js, with the body as a `Buffer` (in Express, `express.raw({ type: 'application/json' })` gives you one):

```js title="verify.mjs"
import crypto from 'node:crypto';

export function verify(body, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(body).digest();
  const given = Buffer.from(parts.v1 || '', 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
```

To test your code, this body:

```text
{"Bucket":"u7-assets","ConfigurationId":"31","Event":"s3:TestEvent","Service":"Coritan Object Storage","Time":"2026-10-10T11:52:03.456Z"}
```

signed at `t=1791633125` with the secret `example-secret` gives `v1=e398419dd7237b7e3484c6a65fcd562ef5dc056ab280f1fcd08e6971e2c3c174`. Pass a large `tolerance` when you check it, because that moment has passed.

## Retries and pausing {#retries}

A delivery works when your address answers with a `2xx` status within 10 seconds. Any other status, a redirect, a timeout or a failed connection is a failed attempt. We do not follow redirects.

After a failed attempt we try again 1 minute later, then after 5 minutes, 30 minutes, 2 hours and 6 hours. A delivery that fails all six attempts is `Failed`, and we do not send it again.

When every delivery of a rule has failed for a day, with none working since the first failure, we pause the rule:

- We stop sending to its address and drop the events waiting to be sent.
- We email the account's owner.
- A warning above the rules names the paused rule and offers **Turn on**.

Fix the address, select **Send test event** to check it (a test works on a paused rule), then select **Turn on**. The rule sends changes made from then on. Changes made while it was paused are not sent.

## Read the delivery log

The **Delivery log** card under the rules shows what we sent for one rule in the last 30 days, newest first, 25 to a page. Select **Show deliveries** in a rule's menu to show that rule's log, and use the lists above the log to choose another rule or one status. The address keeps both choices, such as `?rule=31&status=failed`, so you can bookmark or share the view.

**Event**
: The event name and the object's key. A test shows `Test event`.

**Status**
: `Delivered`; `Queued` or `Waiting to retry`, with when the next attempt is due; `Sending`; `Failed` after the last attempt; `Not sent` when the rule was paused or deleted first.

**Response**
: The status your address answered and the start of its reply, or `No answer` when it did not answer.

**When**
: When the change happened, and the object's size.

## Change, pause or delete a rule

Open the rule's menu on the **Event notifications** card:

- **Edit rule…** changes the address, the events, the filters or the description. The same checks apply as when you add a rule.
- **Pause rule…** stops sending and drops the events waiting. **Turn on** starts it again for changes made from then on.
- **Delete rule…** asks you to type the rule's name. Events waiting to be sent are dropped, and its delivery log goes with it.

> [!CAUTION]
> Deleting a rule cannot be undone. The events it would have sent while it was gone are not sent if you add it again.

## Rotate the signing secret

Select **Rotate signing secret…** in the rule's menu. The dialog shows the new secret once. The old one stops at once: every message from then on is signed with the new secret, including retries of earlier events. Update your receiver straight away. Messages it refuses in the meantime fail and are tried again on the schedule above.

## Address rules {#address-rules}

We refuse an address that:

- does not start with `https://`;
- holds a user name or password (check the signature instead);
- is a name or address of Coritan's own;
- resolves to a private, loopback or link-local network;
- is longer than 2048 characters.

We check the address again before every delivery, so a name that later resolves to a private network fails then.

## Limits

| What | Limit |
| --- | --- |
| Rules per bucket | 100 |
| **Prefix** and **Suffix** | 1024 characters each |
| **Description** | 100 characters |
| Answer we wait for | 10 seconds |
| Attempts per delivery | 6 |
| Delivery log | 30 days |
| Rule changes (add, edit, delete, rotate) | 120 an hour per account |
| Test events | 30 an hour per account |

## With the API

Every call needs an access token (`$CORITAN_TOKEN`). The service ID and the bucket ID come from `GET /api/v1/client/object-storage/{service_id}/buckets`.

Add a rule. The answer holds the rule and its `signing_secret`, shown this once:

```bash
curl -X POST https://api.coritan.com/api/v1/client/object-storage/106/buckets/501/notifications \
  -H "Authorization: Bearer $CORITAN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/storage", "events": ["object-create"], "prefix": "photos/", "suffix": ".jpg", "description": "Thumbnail builder"}'
```

`events` takes `object-create`, `object-delete` or both. The other operations:

| Operation | What it does |
| --- | --- |
| `GET .../notifications` | Lists the rules, with `limits` and what each event covers. The secret is never listed. |
| `PUT .../notifications/{rule_id}` | Changes the fields you send. `"enabled": false` pauses the rule; `true` turns it back on. |
| `DELETE .../notifications/{rule_id}` | Deletes the rule. |
| `POST .../notifications/{rule_id}/test` | Sends a test event now and answers `delivered`, `response_status` and `response_body`. |
| `POST .../notifications/{rule_id}/rotate-secret` | Answers a new `signing_secret`. |
| `GET .../notifications/{rule_id}/deliveries` | The delivery log, newest first. Takes `status`, `limit` (1–200, 50 by default) and `before_id`, the `next_before_id` of the previous page. |

A refused change answers `422` for a bad address, event or filter, `409` for an overlap, a bucket with 100 rules or a service that is not `active`, and `429` past the hourly limits. The `detail` says which.

## Troubleshooting

`The address must start with https://`
: Use an HTTPS address. We send nothing over plain HTTP.

`The address must be outside Coritan`
: The address is one of Coritan's own names. Use your own domain.

`The address must be on the public internet, not a private, loopback or link-local network`
: The name resolves to an address such as `10.0.0.5` or `127.0.0.1`. Point it at a public address.

`We could not find that host name`
: The name does not resolve. Check its spelling and its DNS records.

`This rule overlaps rule <id>`
: Another rule on the bucket covers the same event for some of the same keys. Change the **Prefix** or **Suffix** so no key matches both, or edit the other rule ([Overlapping rules](#overlapping-rules)).

The rule shows `Failing`
: Open the **Delivery log** and read **Response**. A `401` or `403` usually means your receiver refused the signature: check it uses the latest secret and the raw body. `No answer` means we could not connect or your address took longer than 10 seconds.

The signature never matches
: Your framework parsed and rewrote the body before you hashed it. Hash the bytes as they arrived, and key the HMAC with the secret exactly as shown, with no spaces or line break.

No event arrives for a change
: Check that the rule is `Active`, that its **Events**, **Prefix** and **Suffix** cover the key, and that the change happened after you added or turned on the rule. Allow a minute or two.

## Related

- [Create and delete buckets](/docs/object-storage/buckets/)
- [Upload, download and delete objects](/docs/object-storage/objects/)
- [Create and revoke access keys](/docs/object-storage/access-keys/)
- [Object Storage limits](/docs/object-storage/limits/)

## API

- `GET /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications`: The bucket's event notification rules, oldest first (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-get-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications)
- `POST /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications`: Add a rule: POST each matching event in the bucket to url (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-post-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications)
- `PUT /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications/{rule_id}`: Change a rule (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-put-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications-rule)
- `DELETE /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications/{rule_id}`: Delete a rule (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-delete-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications-r)
- `POST /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications/{rule_id}/test`: Send test notification (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-post-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications-rul)
- `POST /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications/{rule_id}/rotate-secret`: Replace the rule's signing secret and answer the new one, shown this once (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-post-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications-rul)
- `GET /api/v1/client/object-storage/{service_id}/buckets/{bucket_id}/notifications/{rule_id}/deliveries`: The rule's deliveries of the last 30 days, newest first (https://www.coritan.com/docs/api/reference/client/object-storage/object-storage-buckets/#op-get-api-v1-client-object-storage-service-id-buckets-bucket-id-notifications-rule)
