Contents
REST API - Plugixa Activity Log
Everything the admin screens do, they do through this API. There is no private back channel, so anything the app can read, a script can read with the same permissions.
No route creates, edits or deletes an event. That is the design, not an omission. Events are written inside WordPress by the monitors; the API cannot forge one and cannot erase one. The only writes here change configuration, which affects what is recorded from now on and rewrites no history.
Namespace
/wp-json/plugixa-activity-log/v1/
Every successful response has the same envelope:
{ "success": true, "data": [], "meta": {} }
meta is present only when there is something to say, such as paging. Errors
are standard WordPress REST errors with a plugixa_activity_log_* code, for
example plugixa_activity_log_forbidden or plugixa_activity_log_not_found,
and the HTTP status to match.
Authentication
Two ways, and both end at the same permission checks.
Cookie and nonce, from inside wp-admin. Send the signed-in user’s cookies
and an X-WP-Nonce header holding a wp_rest nonce. This is what the admin app
does.
Application Passwords, from anywhere else. Create one on the account’s profile under Application Passwords. WordPress requires HTTPS for them, except on a local site. Then use HTTP Basic authentication:
curl -u 'alex:abcd efgh ijkl mnop qrst uvwx' \
'https://example.com/wp-json/plugixa-activity-log/v1/events?min_severity=high&per_page=50'
The account needs access to the log: an administrator, or a role listed under Settings -> Access. The tables below say which level each route needs:
| Needs | Who has it |
|---|---|
| view | Administrators, and the roles allowed to see the log |
| settings | Administrators. On multisite, network administrators |
| manage | Anybody with view or settings |
| export PRO | Administrators, and the roles allowed to export |
See Access.
GET /events
A page of events, newest first.
GET /wp-json/plugixa-activity-log/v1/events?group=authentication&action=failed
{
"success": true,
"data": [
{
"id": 48211,
"occurred_at": "2026-10-08T09:14:02Z",
"event_code": 1003,
"severity": "medium",
"group": "authentication",
"group_label": "Sign-ins",
"site_id": 1,
"user": { "id": 5, "login": "sam", "name": "Sam Lee", "exists": true },
"user_role": "editor",
"ip": "203.0.113.9",
"object": { "type": "user", "id": 5, "label": "sam", "url": "" },
"action": "failed",
"message": "Failed sign-in attempt for \"sam\": the password was wrong."
}
],
"meta": { "has_more": true, "next_before_id": 48211, "newest_id": 48211, "gap": false }
}
occurred_at is UTC. user.login is the login recorded at the time;
user.exists says whether that account is still there.
Paging parameters
| Parameter | Meaning |
|---|---|
per_page |
1 to 200. Defaults to the Events per page setting, itself 50 by default |
before_id |
Only events older than this id. Pass meta.next_before_id to fetch the next page |
after_id |
Only events newer than this id: the live tail |
with_total |
Also count the matches, into meta.total |
These four are enforced: a per_page of 500 is a 400 error, because a wrong
paging value is a bug in the caller.
Keyset paging
Pages are fetched by cursor, not by page number. There is no page and no
offset. A numbered page 4,000 would make the database walk past every earlier
row first; a cursor jumps straight to the place, so the last page of a
ten-million-row log costs what the first did.
To read everything, follow meta.next_before_id until meta.has_more is false:
GET /events?per_page=200
GET /events?per_page=200&before_id=48012
GET /events?per_page=200&before_id=47812
To read only what is new since your last call, remember the highest id you
have seen (meta.newest_id) and pass it as after_id next time:
GET /events?after_id=48211
meta.gap is true when more new events arrived than fit in one page. A poller
that sees it should reload from the top instead of assuming it has everything.
meta key |
Meaning |
|---|---|
has_more |
There is another page in the direction you are reading |
next_before_id |
The id of the last event on this page, or null for an empty page |
newest_id |
The id of the first (newest) event on this page, or null |
gap |
An after_id poll came back full |
with_total
Totals are computed only when asked for, because a poll every few seconds must not pay for a count.
meta key |
Meaning |
|---|---|
total |
The number of matching events |
total_capped |
True when a filtered count stopped at 10,000: read it as “10,000 or more” |
total_approximate |
True when nothing is filtered and the log is larger than 10,000: the total is the database’s own estimate of the table |
Counting is exact up to 10,000. Beyond that, an exact figure would mean scanning the table, which is the one thing this endpoint never does.
Filter parameters
| Parameter | Matches |
|---|---|
severity |
Exact severities, comma separated or repeated: informational, low, medium, high, critical |
min_severity |
At least this severe |
event_code |
Event codes, comma separated or repeated |
group |
An event group key, such as authentication, users or content |
module |
The id of the module that owns the events, such as content |
user_id |
The WordPress user id of the person who acted |
user_login |
The login recorded at the time. Also matches a failed sign-in by the name that was typed |
user_role |
The role the person had at the time |
ip |
An exact client address |
object_type |
The kind of object: user, post, media, comment, term, plugin, theme, option and others |
object_id |
The id of the object |
action |
The verb: created, modified, deleted, login, failed, activated and others |
search |
Free text over the message, the object and the user. At most 100 characters. A term shaped like an address matches the IP by prefix |
date_from |
Start: Y-m-d or ISO 8601, read in the site’s time zone |
date_to |
End, inclusive: Y-m-d or ISO 8601, read in the site’s time zone |
site_id |
A network site id. Only network administrators see other sites |
Filters combine with AND.
A filter value the server does not understand is dropped, never an error. That is deliberate: a hand-edited or stale link then opens a wider view instead of failing. It also means a typo in a filter returns more than you expected, not nothing, so check the result when a filter seems to have been ignored.
On multisite, a site administrator is confined to their own site by the
server, whatever site_id says.
The same parameter list, with descriptions, is in the REST index:
GET /wp-json/plugixa-activity-log/v1.
The other routes
| Route | Method | Needs | Purpose |
|---|---|---|---|
/events/{id} |
GET | view | One event in full, with its decoded details, a link to the object when it still exists, and its definition |
/events/catalogue |
GET | view | Every event the plugin can record, and whether each is on |
/events/catalogue |
PATCH, POST | settings | Switch events on or off |
/stats |
GET | view | Totals and chart data for a window: days (1 to 365, default 30), plus the same filters as /events |
/monitors |
GET | view | The monitors and whether each runs |
/monitors/{id} |
PUT, PATCH, POST | settings | Switch one monitor: { "enabled": true } |
/settings |
GET | settings | The settings, and the choices each control offers |
/settings |
POST, PUT | settings | Save settings. Send only the keys you are changing |
/users |
GET | view | A picker of users: search, at most 20 results |
/views |
GET, POST | view | Saved views you can see, yours and shared ones; create one |
/views/{id} |
PUT, PATCH, DELETE | view | Change or delete a view. Other people’s shared views need settings |
/health |
GET | settings | The whole Health report |
/notifications/test |
POST | settings | Send a test email to the saved recipients |
/import/wsal |
GET | settings | The status of the WP Activity Log import |
/import/wsal/start |
POST | settings | Start or resume it: date_from, skip_unmapped |
/import/wsal/cancel |
POST | settings | Stop it, keeping its position |
/bootstrap |
GET | manage | The admin app’s start-up payload |
An event on another site of the network answers 404 to a site administrator, not 403, so the id’s existence is not revealed.
Switching events
PATCH /events/catalogue
{ "overrides": { "2001": false, "2012": true } }
An unknown code is a 422 and nothing is saved. Only differences from an event’s default are stored, so setting an event back to its default removes the override.
Saving settings
POST /settings
{ "retention_days": 30, "privacy_ip_mode": "truncate" }
The response is the full settings as saved. A key the plugin does not recognise is discarded, and every value goes through the same checks as the Settings screen, so read the response to see what was stored. The change is recorded in the log as event 9000.
Saved views
A view is a name plus a set of log filters. Anyone who can read the log can save
views for themselves, at most 50. Only somebody with settings access can share a
view with every reader, or change and delete other people’s shared views.
is_default marks the view the log opens with.
POST /views
{ "name": "Failed sign-ins", "filters": { "group": "authentication", "action": "failed" }, "is_default": true }
See Saved views.
Pro routes PRO
These exist only when Plugixa Activity Log Pro is installed. On the free build
they answer rest_no_route, because the code is not there: no licence check
refuses them.
| Route | Method | Needs | Purpose |
|---|---|---|---|
/export PRO |
GET | export | The events matching the same filters as /events, as a CSV file, at most 50,000 rows |
/reports/run PRO |
POST | export | Build a report and download it |
/reports/schedules PRO |
GET, POST | settings | List scheduled reports; create one |
/reports/schedules/{id} PRO |
PUT, PATCH, DELETE | settings | Change or delete a schedule |
/reports/schedules/{id}/run-now PRO |
POST | settings | Send a scheduled report now |
/rules PRO |
GET, POST | settings | List alert rules; create one |
/rules/{id} PRO |
GET, PUT, PATCH, DELETE | settings | Read, change or delete a rule |
/rules/{id}/test PRO |
POST | settings | Send a sample to every channel of the rule |
/sessions PRO |
GET | signed in | Your own sessions. With settings access, everybody’s: user_id, role, search |
/sessions/end-others PRO |
POST | signed in | End your other sessions |
/sessions/{user_id}/end-all PRO |
POST | own, or settings | End all sessions of a user |
/sessions/{user_id}/{verifier} PRO |
DELETE | own, or settings | End one session |
/geolocation/status PRO |
GET | settings | The IP location database and its last update |
/geolocation/update PRO |
POST | settings | Download the database now |
/files/status PRO |
GET | settings | The file scan’s status |
/files/scan PRO |
POST | settings | Start a scan |
/archive/events PRO |
GET | view | A page of archived events. Same parameters as /events |
/archive/events/{id} PRO |
GET | view | One archived event |
/archive/status PRO |
GET | settings | What the archive holds |
/archive/settings PRO |
GET, POST, PUT | settings | Read or save the archive settings |
/archive/test-connection PRO |
POST | settings | Try the external database connection |
/archive/run-now PRO |
POST | settings | Archive now |
/mirrors PRO |
GET, POST | settings | List mirrors; create one |
/mirrors/{id} PRO |
GET, PUT, PATCH, DELETE | settings | Read, change or delete a mirror |
/mirrors/{id}/test PRO |
POST | settings | Send a test event to a mirror |
Even here, nothing edits an event. The deletes in this table remove a rule, a schedule, a mirror or a sign-in session, never a log entry. The archive moves events; it does not change them. Every CSV download is itself recorded, as event 9003.
Request bodies PRO
The routes that create something take JSON. A change (PUT or PATCH) takes
the same fields, and a field you leave out keeps its stored value. A request the
plugin cannot accept answers 422 with a sentence saying why.
An alert rule. name and at least one channel are required.
POST /rules
{
"name": "Administrator sign-ins from outside the office",
"enabled": true,
"conditions": {
"groups": ["authentication"],
"min_severity": "medium",
"user_roles": ["administrator"],
"ips": ["203.0.113.0/24"]
},
"channels": [
{ "type": "email", "target": "security@example.com" },
{ "type": "webhook", "target": "https://hooks.example.com/activity", "secret": "a-signing-secret" }
],
"throttle_minutes": 15
}
| Field | Values |
|---|---|
conditions.codes |
Event codes. Codes of 9000 and above are dropped |
conditions.groups |
Area slugs, as the group filter of /events uses them |
conditions.min_severity |
informational, low, medium, high or critical |
conditions.severities |
An exact list of the same severity names |
conditions.user_roles |
Role slugs |
conditions.user_ids |
User IDs |
conditions.ips |
Single addresses or CIDR ranges |
conditions.object_types |
Object type slugs |
conditions.search |
Text to find, at most 100 characters |
channels[].type |
email, slack, discord, teams or webhook. At most 10 channels |
channels[].target |
An email address, or an https:// address |
channels[].secret |
Webhook only: the signing secret |
throttle_minutes |
0 to 1440 |
Every condition that is filled in must hold, and an empty one does not narrow.
severities and user_ids have no field in the rule editor. A stored signing
secret is returned masked; send the masked value back to keep it. See
Alert Rules.
A report, built and downloaded now.
POST /reports/run
{
"title": "Sign-ins, last 7 days",
"format": "html",
"days": 7,
"sections": ["summary", "by_user", "events"],
"filters": { "group": "authentication" }
}
| Field | Values |
|---|---|
format |
csv (default) or html |
days |
1 to 365, default 30. Or give date_from and date_to instead |
sections |
Any of summary, by_severity, by_group, by_user, by_ip, events. All of them when left out |
filters |
severity, min_severity, event_code, group, module, user_id, user_login, user_role, ip, object_type, object_id, action, search, site_id |
A scheduled report. name is required.
POST /reports/schedules
{
"name": "Weekly security summary",
"frequency": "weekly",
"weekday": 1,
"hour": 7,
"format": "html",
"sections": ["summary", "by_severity", "events"],
"filters": { "min_severity": "high" },
"recipients": ["owner@example.com"]
}
| Field | Values |
|---|---|
frequency |
daily, weekly (default) or monthly |
weekday |
Weekly only: 0 (Sunday) to 6. Default 1 |
monthday |
Monthly only: 1 to 28. Default 1 |
hour |
0 to 23, on the site’s clock. Default 7 |
format |
html (default) or csv |
sections, filters |
As for /reports/run |
recipients |
Email addresses, at most 20 |
enabled |
true (default) or false |
See Reports.
A mirror. name and type are required, a site can have at most 20, and
the type cannot be changed afterwards.
POST /mirrors
{
"name": "Central syslog",
"type": "syslog",
"config": { "host": "logs.example.com", "port": 6514, "transport": "tls" },
"filters": { "min_severity": "low" }
}
type |
config fields |
|---|---|
syslog |
host, port (default 514), transport: udp (default), tcp or tls |
http |
url (https:// only), token |
file |
keep_days: 1 to 365, default 14 |
filters takes min_severity and groups. A stored token is returned
masked: send the masked value back to keep it, or an empty one to clear it. See
Mirrors.
An export takes no body: GET /export reads the same filter parameters as
GET /events.
Recipes
Failed sign-ins today, most recent first:
curl -u 'logreader:xxxx xxxx xxxx xxxx xxxx xxxx' \
'https://example.com/wp-json/plugixa-activity-log/v1/events?group=authentication&action=failed&date_from=2026-10-08'
Everything one person did, with a count:
curl -u 'logreader:xxxx xxxx xxxx xxxx xxxx xxxx' \
'https://example.com/wp-json/plugixa-activity-log/v1/events?user_login=alex&with_total=1'
Poll for new high-severity events from a monitoring tool:
curl -u 'logreader:xxxx xxxx xxxx xxxx xxxx xxxx' \
'https://example.com/wp-json/plugixa-activity-log/v1/events?min_severity=high&after_id=48211'
Troubleshooting
| Symptom | Usual cause |
|---|---|
401 or 403, plugixa_activity_log_forbidden |
The account has no access, or the route needs a higher level than view. |
| Refused although you are signed in to wp-admin | Cookie requests need the X-WP-Nonce header. Without it WordPress treats the request as signed out. |
| Application Passwords are not offered on the profile | The site is not on HTTPS. |
rest_no_route |
A Pro route on a free build, or the module behind the route is switched off. |
400 on per_page |
It must be between 1 and 200. |
| A filter returned everything | Its value was not understood, and was dropped. |
total stops at 10,000 |
total_capped is true. The count is “10,000 or more”. |
| Times look wrong | occurred_at is UTC. date_from and date_to are read in the site’s time zone. |
| A site administrator sees fewer events than the network administrator | They are confined to their own site. |