---
title: Notify a service when items change
description: Declare a hook on a collection and Quilium calls your URL each time an item is created, updated or trashed. No code on the site, and the same call whichever door the change came through.
updated: 2026-11-04
---

A hook tells another system that a collection changed: a CRM that wants every new request, a search index, a Slack relay, a rebuild trigger. You declare it in the collection's YAML. Quilium then sends the item as JSON to your URL, from its own servers, after the save.

Hooks exist on **collections only**, not on pages, blocks or global fields. To forward a form submission rather than react to an item, use the `http` action of the form instead: see [custom fields](/en/docs/custom-fields).

## 1. Declare the hook

`hooks` is a top-level key of the collection. Each event takes a list, so one event can call several URLs.

```yaml
requests:
  name: Requests
  objectName: Request
  hooks:
    created:
      - type: http
        config:
          url: https://example.com/quilium/hook?token=a-long-random-value
    updated:
      - type: http
        config:
          url: https://example.com/quilium/hook?token=a-long-random-value
    deleted:
      - type: http
        config:
          url: https://example.com/quilium/hook?token=a-long-random-value
          method: POST
  tabsets:
    # ...
```

| Key | Required | Notes |
|---|---|---|
| `hooks.created` / `updated` / `deleted` | no | A list of hooks. Declare only the events you need. |
| `type` | yes | `http` is the only type. |
| `config.url` | in practice | Not checked when the YAML is saved. Without it, the hook silently does nothing. |
| `config.method` | no | `POST` by default. `POST`, `PUT` and `PATCH` carry the JSON body; any other method sends none. |

You cannot set your own headers.

## 2. Know what sends what

The trigger is on the server, so the backoffice, the MCP, the CLI, the API, public form submissions and retention all send the same hooks.

| What happens to the item | Event sent |
|---|---|
| Created, duplicated, or received from a public form | `created` |
| Edited, one call per item in a bulk edit | `updated` |
| Reordered by hand in a `sortable` collection | `updated`, with empty `before` and `requested` |
| Moved to the trash by an editor | `deleted` |
| Moved to the trash by [retention](/en/docs/itemset-types#retention) | `deleted` |
| Restored from the trash | `created` |
| Deleted permanently, from the trash or by the 30-day purge | nothing |

Three consequences for the receiver:

- **`deleted` means "trashed".** The item can come back, and when it does you receive `created` with an id you already know. Write by id, so that a second `created` replaces rather than duplicates.
- **You are not told when an item is finally purged.** It already sent `deleted` when it left the site, and it was offline from that moment.
- **A CSV import sends one call per row.** Make sure the receiver can take the volume.

A few changes send nothing because they do not save the item: renaming or deleting a tag in the tag registry, and editing the collection's YAML.

## 3. Read the payload

The body is JSON. **There is no `Content-Type` header**, so a framework that only parses `application/json` will hand you an empty body. Read the raw body and decode it yourself. The request carries `User-Agent: Quilium.io` and `Accept: application/json`.

**`created`**

```json
{
  "action": "created",
  "after": {
    "id": "ceebd0ef-8008-4af5-9c30-cfe1a27ab720",
    "title": "A",
    "price": 10,
    "created_at": "2026-11-04 10:58:52",
    "updated_at": "2026-11-04 10:58:52"
  }
}
```

**`updated`**

```json
{
  "action": "updated",
  "before": { "id": "ceebd0ef-…", "title": "A", "price": 10 },
  "requested": { "title": "B" },
  "after": { "id": "ceebd0ef-…", "title": "B", "price": 10 },
  "changes": { "title": "B" }
}
```

`requested` is what the save asked for, `changes` is what actually changed. `before` and `after` are shortened here: they hold the whole item.

**`deleted`**

```json
{
  "action": "deleted",
  "before": { "id": "ceebd0ef-…", "title": "B", "price": 10 }
}
```

What the item contains:

- The root fields of the collection, in the **default language**. Translations are not included.
- Values **as stored**: a `tag` field holds uuids rather than labels, and relations are not resolved. If you need more, read the item back through the API with its `id`.
- `id`, `created_at`, `updated_at`, in the CMS timezone without an offset.
- Internal columns you should ignore, such as `search` and `_order`. `deleted_at` is not usable: do not read it to tell whether an item is trashed, `action` already says so.

## 4. Protect the endpoint

Quilium **does not sign** the request, and you cannot add an authorization header. The only secret available is the URL itself, hence the `token` in the example above.

- Use a long random token and compare it on your side.
- Use `https`.
- Treat the URL as a secret: it sits in plain text in the site's YAML, so it travels with the settings and with site exports.
- Do not trust the body for anything sensitive. Take the `id` and read the item back through the API.

## 5. Plan for failures

The call runs in the queue, after the save. **A failing hook never blocks or undoes the save**, and the editor sees nothing.

| Your endpoint answers | What Quilium does |
|---|---|
| `2xx` | Done. |
| `4xx` | Gives up, no retry. |
| `5xx`, a timeout (10 seconds) or a connection failure | Tries again right away, three attempts in total, then gives up. |

Nothing keeps a failed call for later, so a long outage loses events. If that matters, reconcile from time to time by listing the collection. Since a retry can repeat a call and calls can arrive out of order, make the receiver idempotent: answer fast, queue the work on your side, and write by `id`.

## 6. Test it

No screen in the backoffice shows deliveries. Point the hook at a request inspector you control, then in the backoffice:

1. Create an item. You should receive `created`.
2. Edit it. You should receive `updated`, with the field you changed in `changes`.
3. Delete it. You should receive `deleted`.
4. Restore it from the trash. You should receive `created`.
5. Delete it, then delete it permanently from the trash. You should receive `deleted` once, and nothing for the permanent delete.

Then swap in the real URL.

## Related

- [Collection-type schema](/en/docs/itemset-types)
- [Custom fields](/en/docs/custom-fields), for the form `http` action
- [Build a complete contact form](/en/docs/contact-form)
