Storage
Every organization gets its own storage bucket, created the first time something is written to it. Files are addressed by an opaque id; you never construct a storage path yourself.
The part worth reading twice: the bucket belongs to the organization, not to Axowl’s shared pool. Where it physically lives follows the org’s storage region, and one org’s files can never land under another org’s prefix — the bucket name is looked up from a registry rather than built from a string.
Authentication
Section titled “Authentication”X-Api-Key: ah_live_…The key must be org-wide. An App-Group-scoped key gets 401 on every endpoint here, because a
file box is an organization-level resource and there is no app-group dimension to narrow it to —
honouring such a key would silently widen its contract.
Writes are gated by the organization’s plan.
| Plan | Included storage |
|---|---|
| Free | 2 GB |
| Pro | 30 GB |
| Pro+ | 100 GB |
| Business | 200 GB |
| Enterprise | Unlimited |
Two things surprise people, so they are stated plainly:
- Trashed files do not count. Deleting frees the quota immediately, even though the bytes are still recoverable.
- Restoring can fail. Because a restore adds those bytes back, it can return
storage_quota_exceededeven though the delete that preceded it always succeeded.
GET /storage/usage reports the exact number the upload gate enforces. If you want to check before
uploading rather than handle a rejection, read it first.
Endpoints
Section titled “Endpoints”GET /api/public/v1/files?trashed=&q=&page=&pageSize=POST /api/public/v1/files multipart/form-data, part "file"GET /api/public/v1/files/{id}/downloadDELETE /api/public/v1/files/{id}POST /api/public/v1/files/{id}/restoreGET /api/public/v1/storage/usageGET /api/public/v1/storage/lifecycle/rulesPOST /api/public/v1/storage/lifecycle/rulesPUT /api/public/v1/storage/lifecycle/rules/{id}DELETE /api/public/v1/storage/lifecycle/rules/{id}Upload
Section titled “Upload”Send multipart/form-data with a part named file. Maximum 10 MB per file.
curl -X POST https://testapi.axowl.com/api/public/v1/files \ -H "X-Api-Key: ah_live_…" \ -F "file=@quarterly-report.pdf"{ "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "fileName": "quarterly-report.pdf", "contentType": "application/pdf", "sizeBytes": 284119, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "uploadedAt": "2026-08-20T11:42:07.482Z", "deletedAt": null}The sha256 is ours, not yours. The server hashes the bytes it actually received; it does not
echo a digest you supplied. Compare it against your own to confirm the transfer was intact.
Filenames are sanitised — path separators and control characters are stripped, and non-ASCII names (Korean, Japanese, emoji) survive intact. The name you get back is the name that was stored.
Download
Section titled “Download”Downloads are not proxied through the API. You ask for a link and fetch the bytes directly from storage.
curl https://testapi.axowl.com/api/public/v1/files/3f2504e0-…/download \ -H "X-Api-Key: ah_live_…"{ "url": "https://…r2.cloudflarestorage.com/…?X-Amz-Signature=…", "fileName": "quarterly-report.pdf", "expiresInMinutes": 10}Three properties of that URL matter:
- It expires in 10 minutes.
- Anyone holding it can fetch the file until it expires. It is a bearer credential — do not log it, and do not put it in a redirect chain you don’t control.
- It always responds with
Content-Disposition: attachment. The file is never rendered inline, so an uploaded HTML or SVG file cannot execute as a page. (The share viewer does render some types inline, but only from a fixed allow-list of raster images, audio, video and PDF — HTML and SVG are not on it and never will be.)
Issuing a link is recorded in the audit trail. Whether the bytes were actually fetched is not — that transfer happens between the caller and storage, so “we issued a link” is the honest limit of what we can attest.
Trash and restore
Section titled “Trash and restore”DELETE is a soft delete. The stored object is not removed.
{ "trashed": true, "retentionDays": 30, "restorableUntil": "2026-09-19T11:42:07.482Z" }| Plan | Retention |
|---|---|
| Free | 7 days |
| Paid | 30 days |
After that window the object is permanently removed. There is no version history behind it — the trash window is the only undo, which is why deletes are reversible and permanent removal is not something you can trigger through this API.
Attachments of approval documents (scope = approval) cannot be trashed through this endpoint —
they are part of a sealed record. The call returns 409 sealed_attachment. Archive or delete the
document itself from Requests & Approvals instead; its attachments follow it into the trash.
Scope and who sees what
Section titled “Scope and who sees what”Every file carries a scope string that says what it belongs to: free (uploaded directly),
project:{id}, approval (attached to an approval/report document), notification. Scopes are
derived from where the file was created — there is no way to move a file between them.
Approval attachments land in the file box the moment they are attached, before the document is submitted, so usage and quota already count them.
An ah_live_ organization key sees the whole organization. In the console the default view is
the caller’s own files; seeing everyone’s files needs the org.org_file.list entitlement
without constraints (owners and admins have it, members get org.org_file.list:creator=self).
The same split applies to the access log and the integrity audit: org.audit_event_log.list
unconstrained is what “view all” means there.
Sharing
Section titled “Sharing”A file carries one canonical link and a mode that says who may open it. You do not mint a new link per recipient — you flip the mode. That is the whole point: when you need to take a file back, there is exactly one thing to revoke instead of a list of issued URLs nobody kept.
https://axowl.com/f/{shareToken}PATCH /api/public/v1/files/{id}/share { "mode": "link_anyone" }POST /api/public/v1/files/{id}/share/rotate| Mode | Who can open it |
|---|---|
restricted (default) | Members of the organization who have permission on the file |
link_anyone | Anybody holding the link, read-only |
curl -X PATCH https://testapi.axowl.com/api/public/v1/files/3f2504e0-…/share \ -H "X-Api-Key: ah_live_…" -H "Content-Type: application/json" \ -d '{"mode":"link_anyone"}'{ "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "fileName": "quarterly-report.pdf", "mode": "link_anyone", "shareToken": "8c1f0b6e-7a41-4b2e-9f3a-2d5c7e1a9b04", "shareUrl": "https://axowl.com/f/8c1f0b6e-7a41-4b2e-9f3a-2d5c7e1a9b04"}shareUrl is null while the file is restricted. That is deliberate — a link you hand out for a
closed file only produces a 403 for whoever receives it.
Read-only is structural, not a policy. The only thing a share link can ever produce is a signed
GET. There is no code path from that token to a write, so you are not relying on us to enforce a
permission bit correctly.
Taking a link back
Section titled “Taking a link back”Two steps, and they are genuinely different:
- Set the mode back to
restricted. The address stops working immediately. It is not destroyed — switch back tolink_anyonelater and the same address works again. Use this for “not right now.” - Rotate. A new token is issued and every link handed out so far dies permanently. Use this when a link reached someone it should not have.
Content URLs already handed to a viewer stay valid until they expire (at most 15 minutes). That ceiling is why the revocation gap is bounded rather than open-ended.
Organization policy
Section titled “Organization policy”A team lead or above can block link sharing for the whole organization (the dedicated
org.setting.block_external_sharing scope — held by the Team Lead preset and implied for
Owner/Admin). When they do, link_anyone requests return:
{ "error": "external_sharing_disabled" }with 409. Turning the policy on also closes every file that was open at the time — the switch
is not just about future shares, and each downgrade lands in the audit trail.
What the recipient gets
Section titled “What the recipient gets”Opening https://axowl.com/f/{shareToken} lands on a viewer page — not a redirect to the raw file.
Images, PDFs, audio and video render in place; anything else offers a download. The page itself is
noindex, and the token is a 128-bit GUID, so links are not enumerable and do not turn up in search.
Behind that page is one unauthenticated read endpoint, in case you want to build your own viewer:
GET /api/public/share/{shareToken} # no API key — the token is the credentialIt returns the file’s metadata plus a content URL good for 10 minutes, 404 if the link no longer
exists, and 403 if the link is valid but the file is not currently shared. Those two are worth
keeping apart in your UI: 404 means “ask for a new link”, 403 means “ask them to re-open it”.
What the audit trail records
Section titled “What the audit trail records”| Event | When |
|---|---|
org.file.share_mode_changed | The mode changed, in either direction, by a person or by the org policy |
org.file.share_token_rotated | The link was rotated — every earlier link died |
org.file.share_viewed | Somebody opened the link. Anonymous: we record a hashed IP and the user agent, nothing else |
Opening the viewer is where the trail ends, same as for downloads: the bytes travel between the viewer and storage, so “the link was opened” is the honest limit of what we can attest.
curl https://testapi.axowl.com/api/public/v1/storage/usage -H "X-Api-Key: ah_live_…"{ "usedBytes": 1288490188, "limitBytes": 2147483648, "remainingBytes": 858993460, "fileCount": 42, "trashedBytes": 10485760, "trashedCount": 3, "trashRetentionDays": 7}limitBytes and remainingBytes are null on unlimited plans.
Lifecycle rules
Section titled “Lifecycle rules”A rule says: files not opened for N days → move to infrequent-access storage (transition_ia) or
→ trash (trash). Rules run on the server every 12 hours. They use the same “last opened” stamp the
console shows — a download link issued through this API, the console, or a share link all count as opening.
curl https://testapi.axowl.com/api/public/v1/storage/lifecycle/rules -H "X-Api-Key: ah_live_…"{ "rules": [ { "id": "c1a2…", "action": "transition_ia", "unaccessedDays": 90, "scope": null, "enabled": true, "planAllows": true, "lastRunAt": "2026-08-23T15:10:00Z", "lastRunCount": 1, "lastRunBytes": 822, "createdAt": "2026-08-23T14:58:00Z" } ], "maxRules": 10, "canCustomize": true, "canTrash": true, "presetDays": 90, "minDays": 30, "maxDays": 3650}curl -X POST https://testapi.axowl.com/api/public/v1/storage/lifecycle/rules -H "X-Api-Key: ah_live_…" -H "Content-Type: application/json" -d '{"action":"transition_ia","unaccessedDays":90}'| Field | Meaning |
|---|---|
action | transition_ia — cheaper storage, same link, billed at the infrequent-access rate. trash — moves the file to the trash; never a permanent delete, trash retention still applies. |
unaccessedDays | 30–3650. On Pro+ the only allowed rule is the preset (90 days, transition_ia, all files). |
scope | Omit for all files, or free, project:, notification. Sealed approval attachments are never trashed by a rule. |
enabled | PUT with only {"enabled":false} pauses a rule and is allowed on any plan. Re-enabling or changing conditions is plan-gated. |
planAllows | false after a downgrade: the rule is kept but not run. |
What the plan allows comes back on every list (maxRules, canCustomize, canTrash). When a write is
outside the plan you get 403 plan_upgrade_required with feature = lifecycle (rules not in plan),
lifecycle_rules (count limit — limit/used included) or lifecycle_custom (days/scope need a higher plan).
Rules never delete from storage directly. “Trash” goes through the same trash as a manual delete, and permanent deletion is still only ever done by retention — so a rule cannot destroy something you can’t get back within the retention window.
Errors
Section titled “Errors”| Status | error | What happened |
|---|---|---|
| 401 | — | Missing/invalid ah_live_ key, or the key is App-Group-scoped |
| 400 | multipart_form_required | Body was not multipart/form-data |
| 400 | no_file | No part named file |
| 400 | file_too_large | Over 10 MB |
| 403 | storage_quota_exceeded | Plan limit would be exceeded — body carries usedBytes and limitBytes |
| 400 | invalid_mode | Share mode was neither restricted nor link_anyone |
| 409 | external_sharing_disabled | The organization blocks link sharing — a team lead or above has to lift the policy |
| 409 | sealed_attachment | The file is an attachment of a sealed approval document — archive/delete the document instead |
| 404 | — | No such file in this org (or already trashed, for endpoints that need it live) |
| 403 | plan_upgrade_required | Lifecycle rule write outside the plan (feature: lifecycle / lifecycle_rules / lifecycle_custom) |
| 400 | invalid_action / invalid_days / invalid_scope | Lifecycle rule body rejected |
storage_quota_exceeded is the one worth handling explicitly: it is not a transient failure and
retrying will not help. Either free space or the organization needs a higher plan.
What this API does not do yet
Section titled “What this API does not do yet”Being explicit so you don’t go looking:
- No folders. Files sit in one flat box per organization.
- No per-recipient links, passwords, or expiry dates on a share. A file has one link and a mode.
Recipient-level control is what
restrictedplus organization membership is for. - No streaming or range requests through the API — the presigned URL supports ranges, so seek against that directly.
- No resumable or multipart upload. 10 MB is the ceiling on this path.