Skip to content

Audit log

A workspace’s audit log records every request the edge let through or turned away, and every change anyone made to the workspace. The workspace’s owners and admins can read all of it. An app’s owners can read that app’s entries, as the next section says.

The edge writes each allowed request to the log before the request reaches your app. If it can’t, it answers 503 and doesn’t pass the request on. So every request that reaches an app is in the log. It also writes each refusal before it answers. A page load sent to sign in isn’t a refusal, so the edge writes it as the redirect goes out, and a first visit doesn’t wait for the log.

Terminal window
forculus audit acme --limit 12
2026-09-29T00:35:11Z mail.sent ana@example.com bo@example.com (invitation_workspace)
2026-09-29T00:35:11Z member.put ana@example.com bo@example.com as member
2026-09-29T00:35:11Z access deny (invalid_credential) bt_fxcm00j6joovjjxj GET /acme/hello/ on hello
2026-09-29T00:35:10Z token.revoke ana@example.com hello: bt_fxcm00j6joovjjxj
2026-09-29T00:35:10Z plan.limit ana@example.com hello: tokens_per_app, 5 on free
2026-09-29T00:35:10Z token.create ana@example.com hello: bt_s2d7g11d8yq24p1r as viewer
2026-09-29T00:35:10Z token.create ana@example.com hello: bt_cb5hky8djuaz040o as viewer
2026-09-29T00:35:10Z token.create ana@example.com hello: bt_wtgq59yof3j2busb as viewer
2026-09-29T00:35:10Z token.create ana@example.com hello: bt_c12w2c7832i15yme as editor
2026-09-29T00:35:10Z access deny (no_credential) - POST /acme/hello/hooks/other on hello
2026-09-29T00:35:10Z access allow - POST /acme/hello/hooks/stripe on hello
2026-09-29T00:34:05Z app.public_paths ana@example.com hello

Entries come newest first, 50 at a time unless you set --limit. Times are UTC. Each line has the time, what happened and who did it, then what it was done to. Depending on the entry, that’s the app, who got which role and what they had before, which limit was hit, which token, secret or version, who an email went to, or what kind of statement ran on the database. A deleted app is still named, with (deleted) after the name, because another app may have that name now.

A request shows as access allow, or as access deny with the reason. Then come the caller, the method and the path, and the app it went to. These lines are from the same log:

2026-09-29T00:35:10Z access allow - POST /acme/hello/hooks/stripe on hello
2026-09-29T00:34:05Z access allow bt_fxcm00j6joovjjxj GET /acme/hello/ on hello
2026-09-29T00:34:04Z access allow sam@example.com GET /acme/hello/ on hello
2026-09-29T00:34:04Z access deny (no_grant) lee@example.com GET /acme/hello/ on hello
2026-09-29T00:33:50Z access allow sam@example.com GET / on a preview of hello (p-0a1b2c3d)

A request to a preview’s address says so, with the preview’s label, so it’s never mistaken for one to the live app. forculus watch ends its line with preview p-0a1b2c3d. A request that reached the preview through the gateway, as forculus curl --preview sends one, reads the same, with the path the preview saw.

The caller is one of:

  • a person’s email
  • a bypass token’s id
  • - when the caller sent no credentials, as on a public path
  • the provider, like webhook:stripe, for a delivery whose signature we checked

A request through the apps domain shows its path with the workspace and app in front, like /acme/hello/. The log keeps only the path, never the query string, because query strings can hold secrets.

An app’s owner who is only a member of the workspace can read that app’s entries with --app:

Terminal window
forculus audit acme --app hello

They get about the last 30 days. Older entries are archived with every app’s together, so only the workspace’s owners and admins read those. Entries that aren’t about an app, like a member added, stay theirs too. So the CLI ends with this on stderr:

As the app's owner, you read about the last 30 days. The workspace's owners and admins read further back.

An app’s owner also doesn’t get the sha256 of a statement someone ran on the app’s database, only its kind, nor of a file’s name someone reached with forculus files, only what they did.

Anyone else, or an app’s owner without --app or with another app’s name, gets:

error: You don't have permission to do that.

An app’s owner who leaves out --app is also told to try it, with the app’s name when they own one:

error: You don't have permission to do that. Try --app hello: you can read the log of apps you own.
Flag What it does
--app hello Only this app. --app acme/hello works too. You can name a deleted app, whose entries stay until they age out. If the name has been reused, you get every app that had it, and a deleted one’s entries say (deleted).
--limit <n> How many entries, from 1 to 500. The default is 50.
--before <time> Only entries before this time: a date like 2026-09-01 (its start, in UTC) or an RFC 3339 time.
--before-id <id> With --before, the id of the last entry you already have.
--csv Print the entries as CSV, oldest first, with a header line. Can’t be used with --json.

When a page comes back full, the CLI prints the command for the next page on stderr, so stdout has only the log:

Older: forculus audit acme --limit 12 --before 2026-09-29T00:34:05.017558Z --before-id e73bbb70-a3ad-4be3-91a9-81b1541575b5

Run it to go back a page. The id stops entries with the same timestamp from being skipped.

With --json there’s no such line. To go back a page, pass the last entry’s at and id as --before and --before-id.

--json prints each entry whole:

Terminal window
forculus --json audit acme --limit 1
[
{
"id": "aa4d2586-56e0-4707-be2b-2ef552192e9b",
"at": "2026-09-29T00:35:11.490815Z",
"source": "control_plane",
"action": "mail.sent",
"decision": null,
"reason": null,
"status": null,
"app_id": null,
"entry": null,
"actor": {
"email": "ana@example.com",
"kind": "user"
},
"host": null,
"method": null,
"path": null,
"detail": {
"template": "invitation_workspace",
"to": "bo@example.com"
},
"ingested_at": "2026-09-29T00:35:11.490815Z"
}
]
Field What it holds
source edge for a request, control_plane for a change.
action access for a request, otherwise the change, like grant.put.
decision, reason, status For a request: allow or deny, why, and the status the edge answered with. An allowed request that reaches your app has no status, because your app sets it. Signing in to an app is an allow with reason session_issued and status 302. A page load with no session, sent to sign in, is a deny with reason sent_to_sign_in, status 302 and an anonymous caller. Nothing reached the app, but nobody was refused either.
app_id, app, app_deleted The app it’s about, by id and by name. A deleted app still has app, with the name it had, and app_deleted is true, because another app may have that name now. app is left out when we can’t tell, and app_deleted when the app isn’t deleted.
entry For a request: browser for the app’s own address, gateway for the apps domain, scheduler for a run of a scheduled job.
actor Who did it. See Who did it.
host, method, path For a request: where it went.
detail For a change: what changed. grant.put and member.put name the email, the role and the role it replaces (was, null for someone new), and member.remove lists the apps the person lost. For a delivery on a verified path, webhook names the provider, the pattern and the verifier_id, and either the check that failed (failure) or which secret matched (matched) with the provider’s signed id (delivery) where it has one. A request let in after its app was deleted, when the app was already gone, is an allow and then a no_app deny, whose takes_back is the allow’s id. Nothing reached the app, so the request isn’t counted toward your plan.
ingested_at When the entry reached the log. Requests arrive a moment after they happen.

actor.kind says what sort of caller it was:

kind Who What else it has
user A person. email, and usually via: cli, browser, session, push_token for an image push, mcp for a request from an AI client they connected, or scim for a change your identity provider made over SCIM with the token email made. The dashboard, the CSV and forculus audit show that last one as (SCIM) after the address. A change made through forculus mcp’s tools is via: cli with client: mcp, which the CLI says of itself. Some entries, such as the mail.sent for an invitation, have no via.
agent One of the workspace’s agents. agent_id, and the credential_id it used. The dashboard and forculus audit show it by its name.
github A GitHub Actions run. repository, run_id, the commit as sha, acts_as for the person who set the deploy up, and credential_id. A pull request’s run adds pull_request. The dashboard and forculus audit show it as GitHub acme/site#4821 (as ana@example.com).
scheduler One of the app’s scheduled jobs. schedule_id, and the run_id of that run.
service A bypass token, or one of our own services. A token has token_id. A service has name: billing for billing events such as a plan change from Stripe, janitor for notices emailed to owners, platform for something the platform did on its own, such as ending a sign-in.
webhook A delivery a verifier let through. provider, pattern and verifier_id.
anonymous Someone not signed in: a visit to a public path, or a page load sent to sign in. Nothing.
operator, staff Forculus’s own people, for example suspending a workspace or taking an app down. Nothing about who. The dashboard and the CLI show them as Forculus.

Some refusals have no actor at all: it’s null. The edge turned the request away before it knew who sent it. Most refusals at an app’s own address for no_credential, invalid_session, ambiguous_session and invalid_handoff are like this, and so are some for invalid_credential and rate_limited. The CSV leaves the cell empty, forculus audit and forculus watch show -, and the dashboard says anonymous, as it does for the anonymous kind.

A workspace’s owners can download the log as a CSV file to open in a spreadsheet. It needs a sign-in in the last 15 minutes, as the export does, so the dashboard may ask you to sign in again.

The CSV is an Enterprise feature. It’s included on Business for early customers, for a limited time. On Free and Team the download is refused with 403 plan_limit, and the button says which plans have it. Reading the log, and the workspace export, which has the whole log your plan keeps, are on every plan.

  • The Activity page’s Download CSV has the window it shows: the last hour, 24 hours, 7 days or 30 days. On an app’s Activity tab, it has only that app’s entries.
  • Settings has Download CSV under Your data for the whole log your plan keeps.

The file is oldest first, one line per entry, with these columns:

time,action,decision,reason,status,actor,app,method,path,detail,id

actor is an email, with (SCIM) after it for a change your identity provider made, token and a bypass token’s id, a service’s name, a provider like webhook:stripe, a GitHub run like GitHub acme/site#4821 (as ana@example.com), or otherwise the kind, like agent, scheduler or staff. It’s empty for an entry with no actor. app is the app’s name, with (deleted) after it for a deleted app. detail is the entry’s detail as JSON. Any column we add later goes on the end.

A spreadsheet runs a cell that starts with =, +, - or @ as a formula, even after spaces, and a caller picks the path they request. So a cell like that gets a ' in front, and the spreadsheet shows it as text. A control character or an invisible one, like a zero-width space or a direction override, is written as its code, like \u{202e}, so nothing in a cell hides or turns the line around.

Each download is in the log as workspace.export, with format: csv and the window and app it covered.

forculus audit --csv writes the same columns for a page of the log, oldest first, so an app’s owner, or anyone else who reads it from the CLI, can take it into a spreadsheet too. It’s on the same plans as the download, and refused with plan_limit on the others:

Terminal window
forculus audit acme --app hello --limit 500 --csv > hello.csv

Each page is oldest first, and the Older: command reads the entries before it, so each later page is older than the last. To join pages into one file in time order, put them together last page first, with one header line at the top.

To keep the log beside the rest of your security team’s, stream it to Splunk, Datadog, a collector of your own or an S3 bucket, as it happens. Or fetch it from the audit API with a cursor, as Read it from the API instead shows. Streaming is on the same plans as the CSV.

forculus watch prints the recent entries, then new ones as they arrive. Give it a workspace, or one app:

Terminal window
forculus watch acme --tail 6
00:35:10 event ana@example.com (cli) hello token.create role=viewer token_id=bt_s2d7g11d8yq24p1r
00:35:10 event ana@example.com (cli) hello plan.limit max=5 plan=free what=tokens_per_app
00:35:10 event ana@example.com (cli) hello token.revoke token_id=bt_fxcm00j6joovjjxj
00:35:11 DENY token:bt_fxcm00j6joovjjxj hello GET /acme/hello/ 401 invalid_credential
00:35:11 event ana@example.com (cli) - member.put email=bo@example.com role=member
00:35:11 event ana@example.com - mail.sent template=invitation_workspace to=bo@example.com

It prints Following acme. Times are UTC. Press Ctrl-C to stop. on stderr and runs until you stop it. (cli) marks something done from a CLI session, (cli, mcp) something done through forculus mcp’s tools on it, and (mcp) something done as that person through an AI client they connected. An agent that runs forculus in a shell shows as (cli). The column after who did it is the app, or - for a change that isn’t about an app.

--denies shows only refused requests:

Terminal window
forculus watch acme/hello --denies
00:34:04 DENY - hello GET /acme/hello/ 401 no_credential
00:34:04 DENY lee@example.com (cli) hello GET /acme/hello/ 404 no_grant
00:34:04 DENY lee@example.com (cli) hello GET /acme/hello/ 404 no_grant
00:34:04 DENY - hello GET /acme/hello/ 401 invalid_credential
00:34:04 DENY - hello POST /acme/hello/hooks/stripe 401 no_credential
00:35:10 DENY - hello POST /acme/hello/hooks/other 401 no_credential
00:35:11 DENY token:bt_fxcm00j6joovjjxj hello GET /acme/hello/ 401 invalid_credential
Flag What it does
--tail <n> How many recent entries to print before following, up to 500. The default is 20. With --denies, the last n refusals, however far back they are. --tail 0 prints only what comes next.
--denies Only refused requests.
--seconds <n> Stop after this many seconds instead of running until you press Ctrl-C.

forculus watch checks for new entries once a second, by when they reached the log. Requests reach the log a moment after they happen, and changes made with the CLI or dashboard reach it at once. So a change can print before a request from a second or two earlier. It can miss an entry that was still on its way, so use forculus audit when you need every line. A name too long for its column is cut short with …, and --json has it whole.

Terminal window
forculus top

forculus top fills the terminal with your workspaces and apps on the left. Select an app to see who has access, its tokens and secrets, a chart of allowed and denied requests, and the stream of requests and changes. The keys are j/k or the arrows to move, f for denies only, r to reload and q to quit. With f, the stream is the latest refusals, however much was let in since. Give it a workspace, forculus top acme, to open on that one. It has no JSON to print, so with --json it says to run forculus watch acme --json instead.

A workspace’s Activity page counts requests allowed and refused, and changes made, over the last hour, 24 hours, 7 days or 30 days. It charts the traffic, and lists the busiest apps, who’s calling and why requests were refused, above a live stream. A refusal is named in plain words there, and the code from the table below shows when you hover over it. Each app’s Activity tab shows the same for that app. The workspace’s page is for owners and admins. An app’s tab is for them and for the app’s owners.

Over 30 days, requests let through are counted by the hour, from the same counts as the usage table. So they match what your plan counts, and a request let in and then found to have no app isn’t in them. The shorter windows count requests let through the same way. No window counts signing in to an app as a request. On a very busy workspace, Who’s calling can take too long over 30 days. Then it says so, and a shorter window shows it.

The Apps page gives owners and admins more about each app: who can open it, who made it, and when it was last opened. Who can open it counts the people it’s shared with, by email or through a group, each once, and how many of them aren’t in the workspace. Whoever made the app isn’t counted while they’re in the workspace, so a private app only its maker can open says not shared yet. It names any domain the app is shared with, since everyone at that domain can open it. An app open to the whole workspace says so as well. Last opened says how long ago a request last reached the app, to the hour: This hour, how many hours or days ago, or Never. Download CSV saves the list with the filter you picked: All, Live or Not deployed. See every app has the whole list, column by column.

The stream checks for new entries every 2 seconds while they’re arriving, and every 15 seconds after three checks in a row find nothing. A tab in the background doesn’t check, and catches up as soon as you look at it again.

A browser sent to sign in isn’t counted as refused. The stream shows it as Sent to sign in, and leaves it out when you pick Refused.

Reason Status What it means
no_credential 401 No session and no token. A browser loading a page is sent to sign in instead, which the log records as sent_to_sign_in.
sent_to_sign_in 302 A page load with no session, sent to sign in and brought back. Not a refusal: nothing reached the app, and the dashboard doesn’t count it as refused.
invalid_session 401 The browser’s session for the app didn’t check out. The edge clears it.
ambiguous_session 401 The browser sent two session cookies for the app’s address, which can mean another app planted one. The page asks the person to clear the site’s cookies. A return from sign-in at /.forculus/session with two is recorded here too, without using up its code. See Signed in twice.
invalid_handoff 401, 405, 302 A return from sign-in at /.forculus/session that didn’t check out: no cookie from the redirect that started it, a code already used or made for another app, or a method other than GET. A page load gets a page saying to sign in again. If the browser is still signed in to the app, the page load goes on to the app instead, with a 302. See Sign in again.
invalid_credential 401 A token or session that isn’t valid: revoked, expired, signed out or malformed.
no_grant 404, 403 The caller is signed in, but the app isn’t shared with them, or their email domain isn’t allowed. They get 404, the answer for an app that isn’t there, so they can’t tell it exists, even as a member of the workspace. A bypass token for another app gets 403.
deleted 404 The app’s workspace has been deleted. Answered exactly as no_app is.
sso_required 403, or 401 for an AI client The workspace wants its people signed in through its own single sign-on, and this session, the browser that approved this AI client, or the session that made this bypass token, agent credential or schedule, wasn’t. Only said once the grant has held. A browser’s session for the app ends, and it goes back to sign in.
no_app 404, 401 or 403 No app answers at that address, or it has nothing deployed. It’s 404 at the app’s own address and for a CLI session. On the apps domain, a request with no token gets 401, and a bypass token gets 403. For an app deleted a moment before, the same request can also have an allow: the edge let it through, then found the app gone.
cross_site_request 403 A page on another site tried to send a write to the app, or open it in a frame, with the person’s session.
rate_limited 429 Too many requests from one client to public paths, or to sign-in, or too many tokens from one address that weren’t valid.
over_quota 402 A Free workspace used twice its monthly requests or 50,000,000 ms of CPU time, or reached a monthly limit on database rows. See Limits.
plan_required 402 A container app in a workspace on Free.
usage_limit 402 A paid workspace is paused. Its usage charges reached the usage limit, an invoice during the month wasn’t paid, or it reached an allowance before we’d finished setting up billing for it. See Billing.
database_full 507 The workspace’s databases are over its plan’s limit. Only its owners and admins get in, as themselves. See Limits.
database_hard 507 The databases grew further, or are 10% over the limit. Nobody gets in, owners and admins included.
storage_full 507 The workspace stores more than its plan allows, counting the files its apps keep. Its apps with files answer nobody, owners included, until there’s room. See Storage.
rows_unmeasured 402 We couldn’t measure a Free workspace’s database rows, or its apps’ file use, for two hours, so its apps with a database or files are paused until we can.
cpu_unmeasured 402 We couldn’t count a Free workspace’s CPU time for two hours, so its Worker apps that run code are paused until we can. Static sites keep answering.
suspended 403 The workspace is suspended, or we’ve taken this app down.
putting_back 503 Someone deployed a version after losing the right to edit the app, and we’re putting the previous version back. Nobody gets in until it’s running again. See Access ended while it went live.
webhook_refused 401 or 429 A delivery to a verified public path failed a check, named in detail.webhook.failure. It’s 429 when the workspace’s verified deliveries are over their limit, or the path’s are over its share (rate_limited). See Public paths and webhooks.
webhook_too_large 413 A delivery to a verified public path had a body over 1 MiB.
access_revoked none, or 403 An open connection was closed because access ended. It’s 403 when access ended while the app was still working on its answer, which then isn’t sent.
session_expired none, or 401 An open connection was closed because the session behind it ended. It’s 401 when the session ended while the app was still working on its answer, which then isn’t sent.
control_plane_unavailable 503 The edge couldn’t check access, so it refused.
limiter_unavailable 503 The edge couldn’t reach its rate limiter for a request to a public path or a return from sign-in, so it refused the request instead of letting it through with no limit.
identity_unavailable 503 The edge couldn’t sign an identity token for the app, so it didn’t pass the request on.
config_error 503 The edge is set up wrong, or a session we issued didn’t check out there. It’s ours to fix, not a problem with the request.
Action When
workspace.create, workspace.update A workspace is made, or its name, allowed domains or scan policy change. A change of scan policy has scan_policy and what it was.
member.put, member.remove, member.leave Someone is added or given a new role, removed, or leaves. member.put says the role they had before.
app.create, app.update, app.delete An app is created, renamed, has its visibility changed, lets AI clients connect or stops them, has the number of containers it runs changed, or is deleted. app.update says what it was before, in was: a change of containers is {"containers": 3, "was": {"containers": 1}}. Unless the app was deleted for good, app.delete says until when it can be restored, in restorable_until. It has taken_back when a first deploy failed and deleted the app it made.
app.imported An app is imported from the dashboard. It names the source and the project, and lists the names in env, secrets and schedules, never a value. An import from the CLI or an agent is logged as the steps it takes instead.
app.restore, app.purge A deleted app is restored, or deleted for good, by someone or by us once its 30 days are up. app.purge by us has service as its actor kind.
workspace.restore, workspace.purge A deleted workspace is restored, or an owner deleted it for good.
sso.kept_sign_in An owner of a deleted workspace signed in through its single sign-on provider, to restore it or delete it for good. Has the session’s expires_at. A restore or delete for good on that session names the same session_id.
app.database_restore_requested An owner asked to put an app’s database back to a moment, in at, or to a bookmark, in bookmark. Written before D1 is asked. The entry after it says how it went.
app.database_restore_started The restore is about to go to D1. previous_bookmark is the database’s bookmark from right before, which undoes it if it runs. Written before D1 is asked, so it’s there however the restore ends.
app.database_restore, app.database_restore_refused The restore ran, or D1 refused it and nothing changed. previous_bookmark undoes one that ran, and restored_to is the bookmark a restore to a time went to. outcome is unknown when D1 never said whether it ran. If the bookmarks it gave couldn’t be saved, previous_bookmark is the one from right before it, and d1_previous_bookmark is the one D1 gave. A refusal has D1’s status. Since 8 October 2026, every restore entry has the database it’s of. See Backups and restore.
app.deploy, app.rollback, app.deploy_failed A deploy, a rollback to an earlier version, or either one failing. Failed deploys lists the reasons. A preview that fails has preview: true.
app.scan, app.deploy_blocked A deploy’s scan found a possible secret or a vulnerable package, or the allow file accepted one, beside the version it was for. Or the scan refused a deploy, which made no version. Each has scan_id, outcome, how many secrets, critical, high, moderate, low and unknown vulnerabilities, allowed, packages, dependencies and the policy. Never a key, only how many.
app.put_back We undid a deploy whose maker could no longer edit the app, once it was done. See Failed deploys.
app.preview, app.promote A version is put up as a preview, or a preview is made live. A pull request’s preview names the pull request and its branch.
app.preview_end A preview was ended by hand, by its version, or a pull request’s preview as the pull request closed or by hand, with the versions that stopped answering.
schedule.add, schedule.update, schedule.remove, schedule.run A scheduled job is added, changed, turned on or off, removed, or run now. The entry names its schedule, cron expression, path and method, and a schedule.update says in turned whether it was turned on or off. One turned off with a run waiting to go has cancelled_run, since that run won’t happen.
grant.put, grant.remove An app is shared, or unshared.
grant.domain_put, grant.domain_remove, grant.group_put, grant.group_remove An app is shared with a domain or a group, or unshared. The entry names the domain or the group.
group.create, group.delete, group.member_put, group.member_remove, group.update A group is made or deleted, someone is added to one or taken out, or your identity provider renames one. Deleting a group names the apps it was shared with.
role.create, role.update, role.delete A custom role is made, changed or deleted. The entry names its permissions, and role.update what they were before.
member.role, group.role A member is given a custom role or has it taken away, or a group starts or stops giving one.
publish_policy.set, publish_policy.remove A publish rule is set, changed or taken off. publish_policy.set names who may, and what the rule was before in was.
publish_policy.end, publish_policy.refuse An owner ended what broke the publish rules, counted in ended, or a rule refused someone, naming the action and what they tried. What an ending took away also has its own entry, with because: publish_policy.
token.create, token.revoke A bypass token is made or revoked.
oauth.connect, oauth.token, oauth.revoke, oauth.reuse Someone approved an AI client for an app, a connected client got new tokens, a connection ended, or a code or refresh token was used twice and the connection ended. See MCP servers behind sign-in.
ai_clients.set, ai_clients.allow, ai_clients.remove An owner or admin chose whether any AI client may connect to the workspace’s apps or only the ones on its list, or added a client to the list or took one off. See Choose which clients can connect.
secret.put, secret.remove, app.env A secret or an environment variable changes. The log names it, never its value.
app.env_not_applied, app.env_applied app.env_not_applied: a change to environment variables was saved, but the app couldn’t be redeployed with it. app.env_applied: a later redeploy put it live. That entry names every variable changed since the last redeploy that worked, from a change, a deploy or a rollback. An env set or env remove that changes nothing but redeploys the app is logged here too. Both name the variables, never their values.
app.public_paths The public paths change. The entry lists every path open afterwards.
webhook.set, webhook.update, webhook.rotate, webhook.retire, webhook.remove A public path’s verifier is added or replaced, has its tolerance changed, gets a new secret, stops accepting the old one, or is removed. The log names the path and the provider, never the secret.
github.set, github.remove An owner names the GitHub repository that may deploy an app, or stops it. A setting we delete because whoever saved it can’t share the app any more is a github.remove by platform, with reason saver_left or saver_cannot_share. See deploying from GitHub Actions.
hook.add, hook.update, hook.remove, hook.failing A webhook or Slack channel is added, changed or removed, or we turned it off after a day of failures. One we turn off because whoever added it isn’t an owner or admin any more is a hook.update by platform, with reason orphaned.
github.credential, github.refused A GitHub Actions run was given a deploy credential, or was turned away: another repository, branch or environment, a pull request with previews off, from a fork (reason github_fork), with a bot’s code in it (github_bot), held to an environment nobody else has to approve, or with nobody else’s approval of the run there, as a pull request’s run is and a push’s is when the setting names an environment and no branch (github_environment), a token used twice, or a setting saved by someone who can’t share the app any more. The actor is the run, with its repository, run id and commit. An app logs one refused run a minute at most, and the next says how many more there were in also_refused.
plan.limit Something was refused because of the plan. The entry names the limit, or the feature: sso, scim, roles, audit_export or customer_keys.
plan.assign, plan.lift We put the workspace on Enterprise, or took it off. The entry names the plan.
mail.sent, mail.skipped An invitation went out, or a daily limit stopped it. The entry names the recipient and the template, never the content: invitation_workspace, invitation_app, or invitation_restored when your identity provider gave someone back access it had taken away. An invitation to an app names the app too, so it shows in that app’s Activity tab and in forculus audit --app. The emails telling owners the workspace was deleted, deleted for good or restored are mail.sent too, with janitor (a service) as the actor, since they go out after the change: notice_workspace_deleted, notice_workspace_gone, notice_workspace_restored, or notice_scim_restored for a restore that tells owners what your identity provider has to do.
billing.checkout, billing.plan, billing.plan_undo An owner starts a subscription, changes plan, or undoes a change.
billing.usage_limit An owner sets the usage limit, removes it, or goes back to the default of $0.
billing.plan_payment, billing.plan_failed A plan change waits for its invoice to be paid, or didn’t go through. The entry has the plans it was from and to, and a failure says why.
billing.credit We credited the workspace. The entry has amount_cents.
usage.unbilled, usage.held, usage.credited Usage we won’t bill, and why. Usage held back while we check it. Usage we billed above Cloudflare’s figure and credited back.
workspace.export, app.database_export Someone exported the workspace or an app’s database, or downloaded the audit log as CSV. A CSV download has format: csv, and the since, until and app it covered.
workspace.suspend, workspace.unsuspend, app.suspend, app.unsuspend We suspended the workspace, or took one app down, after a copyright notice for example. Or we lifted either. The entry gives the reason.
app.files, app.files_refused Someone listed, downloaded, uploaded or deleted an app’s files with forculus files, or was refused. The entry says which in op and has a keyed hash of the file’s name, never the name. An upload refused for the plan’s storage is logged as plan.limit instead.
app.files_failed Storage failed a forculus files call after its app.files entry was written, and the entry is the same as that one’s. Or, with reason storage_limit, deleting an app’s files over the storage limit didn’t start after its app.files_expired entry, and nothing was deleted.
app.files_expired The workspace stayed over its storage for 30 days (7 on Free) because of its apps’ files, so the app’s files are being deleted.
app.kv, app.kv_refused A deploy gave the app key-value storage (op create), or someone listed, read, set or deleted its keys with forculus kv or the dashboard, or was refused. The entry says which in op and has a keyed hash of the key, never the key. op expired is ours: the app’s keys were deleted after 30 days on a plan without key-value storage, and bytes says how much they held. A write refused for the plan’s storage is logged as plan.limit instead.
app.kv_failed Cloudflare failed a forculus kv call after its app.kv entry was written, and the entry is the same as that one’s.
app.queue A deploy gave the app a queue (op create) or set where its messages go (consumer, with the path), we stopped sending its messages because whoever set it up can’t edit the app any more (consumer_off), or someone sent it a message with forculus queue send (send, with a keyed hash of the message and its bytes, never the message).
app.queue_failed Cloudflare failed a forculus queue send after its app.queue entry was written.
app.database_exec, app.database_exec_refused Someone ran a statement on an app’s database with forculus db exec, forculus db query or the dashboard’s SQL console, or was refused, for example because the workspace is suspended. The entry has the statement’s first word in capitals and a keyed hash of the statement, never its text. If the first word isn’t 1 to 16 plain letters, like every SQL keyword, it says OTHER. A query from forculus db query or the console also has read_only, true unless it ran in write mode, and offset for a page after the first, or csv when it was saved as CSV. A refused RESTORE is a restore refused, for example under a hold, or with point set to true, asking where a restore to a time would go, which changes nothing.
audit_stream.add, audit_stream.update, audit_stream.remove An owner added an audit log stream, turned one on or off, or removed one. The entry names its kind and where it sends, never a credential.
audit_stream.test, audit_stream.retry An owner sent a stream a test entry, or sent its failed batches again.
audit_stream.off We turned a stream off: a batch failed for a day (failing), the plan no longer includes streaming (plan), or whoever turned it on is no longer an owner or admin (orphaned).
workspace_key.add, workspace_key.rotate, workspace_key.revoke, workspace_key.restore, workspace_key.remove, workspace_key.discard An owner added the workspace’s own encryption key, moved to another, revoked it, restored it, removed it, or discarded a lost one with everything sealed under it. The entry names the key, never anything sealed. resealed says how many values moved under the key or back under ours. A rotation that only replaced a key service’s signing secret has signing_secret_only.
workspace_key.use We called the workspace’s own key: operation is wrap or unwrap, for says why (use, add, rotate, restore, remove, or webhook or audit_stream to send one), and outcome is ok, refused, unreachable, malformed or wrong_key. A refusal has the key’s status, and AWS’s name for the error. The actor is keys, of kind service. A stream doesn’t send on the ones for audit_stream.
workspace_key.add_refused, workspace_key.discard_refused Adding a key or discarding one was refused, because that can’t be done right now.
postgres.add, postgres.update, postgres.remove An owner connected a Postgres database, changed one (changed says which of host, database, user, password and tls), or removed one. The entry names the connection, its host, database and user, never the password.
postgres.grant, postgres.revoke An owner gave an app a Postgres connection, or took it away. The entry names the connection.
postgres.check, postgres.refused An owner or admin checked a connection, or a database someone tried to connect or change didn’t connect and nothing was kept. A check says whether it worked in ok, and both say what went wrong in error.
log_drain.add, log_drain.update, log_drain.remove Someone added a log drain, turned one on or off, changed the quietest level it sends, or removed one. An app’s drain is in that app’s entries. The entry names its kind and where it sends, never a credential.
log_drain.test, log_drain.retry Someone sent a drain a test line, or sent its failed batches again.
log_drain.off We turned a drain off: a batch failed for a day (failing), the plan has room for fewer drains (plan), or whoever turned it on can no longer manage it (orphaned).
workspace.delete The workspace was deleted, by an owner or by us. When we deleted it, the actor kind is operator. Putting it back is workspace.restore, and deleting it for good workspace.purge.
image.push_token, image.push A container app’s deploy was given a credential to push its image to our registry, or pushed one. image.push has the image’s digest.
domain.add, domain.remove A custom domain was added to an app, or removed. The entry names the hostname.
domain.verified, domain.failed A custom domain started serving the app, or stopped. Both name the hostname, and domain.failed says why in failure.
workspace_domain.add, workspace_domain.verify, workspace_domain.remove A domain for the workspace’s own sign-in was added, proved to be the workspace’s, or removed. The entry names the domain.
sso.set, sso.enforce, sso.remove Single sign-on was set up or changed, who must use it changed, or it was removed. sso.set has the protocol and the issuer, and sso.enforce who must use it, in enforce.
scim.token, scim.token_revoke A SCIM token was made, or revoked. A new one has its last characters in hint, never the token.
agent.create, agent.rename, agent.end An agent was made, renamed or ended. The entry has its agent_id and name. A rename has from and to, and an ending the credentials it revoked and the apps the agent lost.
agent.credential_create, agent.credential_revoke An agent got a credential, or one was revoked. The entry names the credential_id, never the secret, and a new one has its expires_at.
agent.grant, agent.ungrant An agent was given an app, with its role, or the app was taken away from it.
access_request.create, access_request.approve, access_request.deny Someone asked for access to an app, or whoever can share it approved the request or turned it down. The entry has the request_id and the email, never the note they wrote.
egress.allow, egress.deny A host was put on an app’s outbound list, or taken off. The entry has the pattern.
egress.restrict, egress.unrestrict An app’s outbound list was turned on, so the app reaches only the hosts on it, or off, so it reaches anywhere.
ai_key.put, ai_key.remove A company AI key was stored, replaced or removed. The entry names the provider, and a stored key has its last characters in hint, never the key.
ai_grant.put, ai_grant.remove An app was let use a company AI key, up to its monthly_budget_cents, or stopped.
private_network.add, private_network.remove, private_network.token A private network was added or removed, or someone was shown its connector token. The entry names the network, never the token.
private_service.add, private_service.remove A service was put on a private network, or removed. The entry names the service, its network and its host.
private_grant.put, private_grant.remove An app was let reach a private service, or stopped. The entry names the service and its host.
egress_ips.assign, egress_ips.lift We gave the workspace’s apps fixed addresses, listed in addresses, or took them away.
hook.test Someone sent a webhook or Slack channel a test delivery. The entry has the hook_id.
app.database_removed We deleted an app’s database after the workspace stayed over its database limit. The entry has the reason and the bytes it held.
billing.link, billing.update, billing.refund Stripe’s side of the plan: billing was set up for the workspace, its subscription’s status changed, with the plan, or we refunded a subscription, with the reason.
access.denied_summary Requests from nobody signed in that we turned away past what the log keeps one by one in an hour. Once the hour is over they’re counted in one entry for each reason, with the count and the reason.
app.delete_refused, secret.remove_refused, app.env_refused, webhook.remove_refused, workspace.delete_refused, account.delete_refused Someone tried to delete an app, a secret, an environment variable, a public path’s verifier, the workspace or their own account, and was refused. Either the workspace is suspended, or that thing can’t be deleted right now. The entry names what it was, like the deletion’s own entry would. A refused account deletion is in the log of each workspace that blocked it.

An entry that stands for a statement or a file’s name without holding it has a hash of it in sha256, and says which key made it beside it, like "hash": "hmac-v1:k1". That hash is an HMAC-SHA256 under a key only we hold, so nobody can find a short statement or a name from it by trying them, and you can’t find one in the log by hashing it yourself. The same statement or name in the same workspace gets the same hash while hash says the same. When we rotate the key, the number after k goes up, and hashes from before and after never match, even for the same statement or name.

app.database_exec and app.database_exec_refused entries from before 3 October 2026 have no hash, and their sha256 is a plain SHA-256 of the statement, so they never match a newer entry’s.

An app.deploy_failed entry has a reason:

Reason What happened
refused Cloudflare refused the app’s code or rules. The entry has Cloudflare’s message.
rollout_in_progress A container app’s last change was still rolling out.
unavailable We couldn’t put the version live.
interrupted We stopped partway through putting it live. We log this one ourselves.
not_allowed Whoever deployed could no longer edit the app by the time it went up.

A failed rollback also has rollback: true.

A not_allowed deploy whose code never went up has nothing to undo: it’s logged once, as the person who deployed it, and the version that was live stays live. When we undo a not_allowed deploy, we log it ourselves. by names who deployed, and put_back is the version we put back live. If no version was live, we take the app’s code off, and the entry says taken_off: true instead.

Sometimes we can’t undo it at once, or a container app’s rollout is still replacing its containers. Then we log app.put_back once it’s done, with the same version, and put_back or taken_off.

We also put the previous version back up when a deploy of a version with files is interrupted, or a rollback to one stops partway, and log app.put_back the same way.

Plan Kept for
Free 3 months
Team 3 months
Business 12 months
Enterprise 12 months

Older entries are deleted. After a move to a plan that keeps less, entries older than the new plan keeps are deleted 30 days after the change. Owners get an email about it.

To keep a copy, export the workspace. An owner can do this from the workspace’s Settings. The export is one JSON file. It has the audit log your plan keeps, the members, who each app is shared with, the names of tokens, secrets and environment variables, and each public path’s verifier without its secret. Deleting an app leaves its entries in the log until they age out.