Skip to content

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.

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.

PlanIncluded storage
Free2 GB
Pro30 GB
Pro+100 GB
Business200 GB
EnterpriseUnlimited

Two things surprise people, so they are stated plainly:

  1. Trashed files do not count. Deleting frees the quota immediately, even though the bytes are still recoverable.
  2. Restoring can fail. Because a restore adds those bytes back, it can return storage_quota_exceeded even 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.

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}/download
DELETE /api/public/v1/files/{id}
POST /api/public/v1/files/{id}/restore
GET /api/public/v1/storage/usage
GET /api/public/v1/storage/lifecycle/rules
POST /api/public/v1/storage/lifecycle/rules
PUT /api/public/v1/storage/lifecycle/rules/{id}
DELETE /api/public/v1/storage/lifecycle/rules/{id}

Send multipart/form-data with a part named file. Maximum 10 MB per file.

Terminal window
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.

Downloads are not proxied through the API. You ask for a link and fetch the bytes directly from storage.

Terminal window
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.

DELETE is a soft delete. The stored object is not removed.

{ "trashed": true, "retentionDays": 30, "restorableUntil": "2026-09-19T11:42:07.482Z" }
PlanRetention
Free7 days
Paid30 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.

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.

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
ModeWho can open it
restricted (default)Members of the organization who have permission on the file
link_anyoneAnybody holding the link, read-only
Terminal window
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.

Two steps, and they are genuinely different:

  1. Set the mode back to restricted. The address stops working immediately. It is not destroyed — switch back to link_anyone later and the same address works again. Use this for “not right now.”
  2. 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.

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.

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 credential

It 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”.

EventWhen
org.file.share_mode_changedThe mode changed, in either direction, by a person or by the org policy
org.file.share_token_rotatedThe link was rotated — every earlier link died
org.file.share_viewedSomebody 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.

Terminal window
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.

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.

Terminal window
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
}
Terminal window
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}'
FieldMeaning
actiontransition_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.
unaccessedDays30–3650. On Pro+ the only allowed rule is the preset (90 days, transition_ia, all files).
scopeOmit for all files, or free, project:, notification. Sealed approval attachments are never trashed by a rule.
enabledPUT with only {"enabled":false} pauses a rule and is allowed on any plan. Re-enabling or changing conditions is plan-gated.
planAllowsfalse 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.

StatuserrorWhat happened
401—Missing/invalid ah_live_ key, or the key is App-Group-scoped
400multipart_form_requiredBody was not multipart/form-data
400no_fileNo part named file
400file_too_largeOver 10 MB
403storage_quota_exceededPlan limit would be exceeded — body carries usedBytes and limitBytes
400invalid_modeShare mode was neither restricted nor link_anyone
409external_sharing_disabledThe organization blocks link sharing — a team lead or above has to lift the policy
409sealed_attachmentThe 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)
403plan_upgrade_requiredLifecycle rule write outside the plan (feature: lifecycle / lifecycle_rules / lifecycle_custom)
400invalid_action / invalid_days / invalid_scopeLifecycle 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.

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 restricted plus 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.