
- MODX 3
- PHP 8.2


Base URL:
https://your-site/assets/components/reactions/api.phpEntry point file: assets/components/reactions/api.php (loads bootstrap and calls Reactions\Api\Router).
The action comes from the action parameter or PATH_INFO:
GET /assets/components/reactions/api.php?action=counts
GET /assets/components/reactions/api.php/counts
POST /assets/components/reactions/api.php?action=react
GET /assets/components/reactions/api.php?action=admin/typesNested admin resources: action=admin/types, admin/sets, admin/bans, admin/stats.
The HTTP method comes from REQUEST_METHOD. Clients without DELETE can emulate it:
POST + body field _method=DELETE, orPOST + $_POST['_method']=DELETE.All responses are JSON (JSON_UNESCAPED_UNICODE). The success field is always present.
Success (top-level fields depend on the endpoint):
{
"success": true,
"data": { },
"csrf": "…"
}Error:
{
"success": false,
"error": "Human-readable description",
"code": "error_code"
}code | HTTP | When |
|---|---|---|
validation_error | 400 | Missing required fields (class_key, object_id, type…) |
bad_request | 400 | Empty action |
not_found | 404 | Unknown action / object / type / set |
method_not_allowed | 405 | Wrong HTTP method for the endpoint |
forbidden | 403 | CSRF/Origin/nonce, ban, bot, type outside set/full_types, cancelled by plugin |
auth_required | 401 | Admin without reactions_manage. auth_only strategy for a guest |
rate_limit | 429 | Exceeded reactions_rate_limit |
internal_error | 500 | Uncaught exception (details in error.log) |
save_failed | 500 | Admin: failed to save the object |
| Method | action | Description | CSRF |
|---|---|---|---|
| GET | csrf | Issue a CSRF token | no |
| GET | counts | Counters + current visitor reactions | no |
| POST | react | Add / toggle a reaction | yes |
| DELETE | react | Remove a reaction | yes |
| GET | top | Top by likes / rating / total | no |
| GET | trending | Top by trending_score | no |
| GET | latest | Feed of latest reactions | no |
GET endpoints are cached on a CDN only if you configure that yourself. The counts response depends on cookie/session (user_reaction) — do not cache it publicly.
Returns a token bound to the PHP session ($_SESSION['reactions_csrf']). A new call overwrites the previous token.
curl -c cookies.txt \
"https://example.com/assets/components/reactions/api.php?action=csrf"Response:
{
"success": true,
"csrf": "a1b2c3d4e5f6…"
}Then pass the session cookie (-b cookies.txt) on every request that needs the same CSRF.
The JS widget makes this request itself when data-csrf is empty.
Counters for an object and the list of reaction types for the current visitor (by fingerprint / user_id).
| Parameter | Required | Description |
|---|---|---|
class_key | yes | Object class (modResource, msProduct, TicketComment…) |
object_id | yes | Object ID (> 0) |
context | no | Context. Default web |
curl -b cookies.txt \
"https://example.com/assets/components/reactions/api.php?action=counts&class_key=modResource&object_id=1&context=web"Response:
{
"success": true,
"data": {
"class_key": "modResource",
"object_id": 1,
"context": "web",
"counts": {
"like": 42,
"dislike": 3,
"love": 7
},
"total": 52,
"user_reaction": ["like"]
}
}counts — associative array of type name → number.total — sum of all types in the aggregate.user_reaction — array of type names already set by the current visitor (may be empty or hold several names when multi is allowed).Add a reaction. A repeated POST of the same type usually removes it (toggle). In exclusive mode (updown or !allow_multiple) another type replaces the previous one (action=changed).
| Field | Required | Description |
|---|---|---|
csrf | yes | Token from GET csrf |
nonce | yes | One-time string (32 hex recommended). TTL 300 s, at most ~50 active in the session |
class_key | yes | Object class |
object_id | yes | ID |
type | yes | Type name (like, love, fire…) |
context | no | Default web |
set | no | Set key for type validation. Empty → reactions_default_set |
site_url (fail-closed: no headers → 403).class_key (e.g. msProduct) resolves to FQCN / STI, then a check by object_id.set.set=full: type is in reactions_full_types when that setting is not empty.OnBeforeReaction (can cancel).CSRF=$(curl -s -c cookies.txt -b cookies.txt \
"https://example.com/assets/components/reactions/api.php?action=csrf" \
| jq -r '.csrf')
NONCE=$(openssl rand -hex 16)
curl -s -b cookies.txt -c cookies.txt \
-X POST "https://example.com/assets/components/reactions/api.php?action=react" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d "{
\"csrf\": \"$CSRF\",
\"nonce\": \"$NONCE\",
\"class_key\": \"modResource\",
\"object_id\": 1,
\"type\": \"like\",
\"context\": \"web\",
\"set\": \"github\"
}"Response:
{
"success": true,
"data": {
"action": "added",
"counts": { "like": 43, "dislike": 3 },
"total": 46,
"user_reaction": ["like"],
"type": "like"
}
}action | Meaning |
|---|---|
added | New reaction |
removed | Repeated click / removal |
changed | Type change in exclusive mode |
full and type subsets { "set": "full", "type": "beer", ... }If reactions_full_types=like,love,fire and the client sends type=beer → 403 forbidden, even when the type exists in the DB set full.
The snippet parameter &types= narrows UI only (buttons / data-types). The API for set=full also filters by reactions_full_types. A type outside the UI but allowed by the API can still be sent via a manual POST. For a hard ban use OnBeforeReaction or narrow the set in the manager/CLI.
Remove the given type. Body fields match POST (csrf, nonce, class_key, object_id, type, context, set).
curl -s -b cookies.txt \
-X DELETE "https://example.com/assets/components/reactions/api.php?action=react" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d "{
\"csrf\": \"$CSRF\",
\"nonce\": \"$NONCE\",
\"class_key\": \"modResource\",
\"object_id\": 1,
\"type\": \"like\",
\"context\": \"web\",
\"set\": \"github\"
}"In the response action is usually removed. After removal the package still fires events, recalculates the aggregate, and (when enabled) the webhook. Author notification is not sent for DELETE.
Top objects by aggregates (or a recount over raw reactions for a period when period ≠ all).
| Parameter | Default | Description |
|---|---|---|
class_key | modResource | Object class |
sort | likes | likes, rating, total |
period | all | day, week, month, year, all |
context | (empty) | Context filter. Empty means all |
limit | 20 | 1–100 |
offset | 0 | Offset |
curl "https://example.com/assets/components/reactions/api.php?action=top&class_key=modResource&sort=likes&period=week&limit=10"Response:
{
"success": true,
"data": {
"items": [
{
"class_key": "modResource",
"object_id": 42,
"context": "web",
"counts": { "like": 120, "dislike": 5 },
"total": 125,
"likes": 120,
"dislikes": 5,
"rating": 115,
"trending_score": 2.079181
}
],
"limit": 10,
"offset": 0,
"total": 10
}
}Here data.total is the number of items on the current result page (not the full table count).
Sort by trending_score (Reddit hot formula). The period parameter does not change the window for trending — the package uses the current score in the aggregate.
| Parameter | Default | Description |
|---|---|---|
class_key | modResource | Class |
context | (empty) | Filter |
limit | 20 | 1–100 |
offset | 0 | Offset |
curl "https://example.com/assets/components/reactions/api.php?action=trending&class_key=msProduct&limit=5"Response shape matches top (list of items with metrics).
Feed of latest reactions (raw rows, not aggregates). The public response does not include user_id.
| Parameter | Description |
|---|---|
class_key | Optional filter |
context | Optional filter |
limit | 1–100, default 20 |
offset | Offset |
curl "https://example.com/assets/components/reactions/api.php?action=latest&class_key=modResource&limit=10"Response:
{
"success": true,
"data": {
"items": [
{
"id": 501,
"class_key": "modResource",
"object_id": 42,
"context": "web",
"type": "like",
"emoji": "👍",
"created_at": 1710000000
}
],
"limit": 10,
"offset": 0,
"total": 10
}
}created_at is a Unix timestamp. total is the current page size.
Requires an authenticated manager user (or the front end with the same session) with the reactions_manage policy.
Without permission → 401 / auth_required.
Admin mutations (POST/DELETE) also require Origin + CSRF + nonce, same as react (see guardMutation).
| Method | action | Description |
|---|---|---|
| GET/POST/DELETE | admin/types | Reaction types |
| GET/POST/DELETE | admin/sets | Sets and type bindings |
| GET/POST/DELETE | admin/bans | IP / user bans |
| GET | admin/stats | Summary |
curl -b mgr-cookies.txt \
"https://example.com/assets/components/reactions/api.php?action=admin/types"{
"success": true,
"data": {
"items": [
{
"id": 1,
"name": "like",
"emoji": "👍",
"icon": null,
"ordering": 10,
"active": true
}
]
}
}Create or update. Fields: id (for update), name, emoji, icon, ordering, active.
curl -b mgr-cookies.txt \
-X POST "https://example.com/assets/components/reactions/api.php?action=admin/types" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"csrf": "'"$CSRF"'",
"nonce": "'"$NONCE"'",
"name": "favorite",
"emoji": "⭐",
"ordering": 90,
"active": true
}'Update:
{ "id": 9, "emoji": "🌟", "active": false, "csrf": "…", "nonce": "…" }curl -b mgr-cookies.txt \
-X DELETE "https://example.com/assets/components/reactions/api.php?action=admin/types" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{"id":9,"csrf":"…","nonce":"…"}'You can also pass id as a query parameter.
List of sets with bound types.
Fields: id, key, title, exclusive, active, types (array of names or IDs).
curl -b mgr-cookies.txt \
-X POST "https://example.com/assets/components/reactions/api.php?action=admin/sets" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"csrf": "'"$CSRF"'",
"nonce": "'"$NONCE"'",
"key": "social",
"title": "Social",
"exclusive": false,
"active": true,
"types": ["like", "love", "funny"]
}'exclusive=true — mutually exclusive types (like updown). Multi on the front still needs reactions_allow_multiple=Yes.
Body: { "id": 3, "csrf": "…", "nonce": "…" }.
Ban by IP (hashed on the server) or user_id.
curl -b mgr-cookies.txt \
-X POST "https://example.com/assets/components/reactions/api.php?action=admin/bans" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"csrf": "'"$CSRF"'",
"nonce": "'"$NONCE"'",
"ip": "203.0.113.10",
"reason": "spam",
"expires_at": 1711000000
}'Or: { "user_id": 42, "reason": "abuse", "csrf": "…", "nonce": "…" }.
expires_at is a Unix timestamp. Omit it for a permanent ban.
curl -b mgr-cookies.txt \
"https://example.com/assets/components/reactions/api.php?action=admin/stats"{
"success": true,
"data": {
"totals": {
"reactions": 1500,
"aggregates": 320,
"types": 8,
"bans": 2,
"today": 45
},
"top_liked": [
{
"class_key": "modResource",
"object_id": 12,
"context": "web",
"likes": 90,
"total": 100,
"trending_score": 1.5
}
],
"top_trending": [ ]
}
}today — reactions with created_at from the start of the current server day. Tops — up to 10 rows.
| Mechanism | Details |
|---|---|
| CSRF | Session token. Required for POST/DELETE (public react and admin mutations) |
| Nonce | One-time, TTL 300 s. Replay protection |
| Origin | Compare host of Origin or Referer with host of site_url. No headers → reject |
| Rate limit | reactions_rate_limit / reactions_rate_limit_window per fingerprint |
| Bots | reactions_block_bots + User-Agent detector |
| Bans | ReactionBan table by IP-hash / user_id |
| Permissions | Admin only with reactions_manage |
Widget requests use credentials: 'same-origin' — the session cookie is sent automatically.
For CDN-hosted reactions.js, set the API explicitly (data-api or window.Reactions.config.api on the same origin as the site).