Files
ignacionelson ddf09677f0 Document the branding endpoints where their callers are
Branding moved into the application on 2026-08-28 and its two read-only
endpoints came with it unchanged -- same paths under
/api/v1/modules/branding, same capability, same ability. Their
documentation did not: the guide still sent readers to
packages/cloud-modules/docs/api.md, so endpoints that every installation
now carries were described in a private repository almost none of their
callers can open.

The OpenAPI document is still not the place for them. OpenApiContractTest
skips api/v1/modules/* on purpose: that document is served
unauthenticated and has to be identical on every installation, while a
module's paths exist only where the module does. So this is a plain
markdown file beside the guide, and published like it -- ignored docs are
maintainer notes, and this one is for integrators.

It is the cloud-modules file moved across, minus the attribution switch:
that half stayed Cloud-only and has no API surface at all. The gate is
described as every edition holding branding.customize, with a hosted plan
able to subtract it, because that is what the enum now says.
2026-09-02 18:56:55 -03:00

504 lines
20 KiB
Markdown

# ProjectSend API guide
The API lets an external tool act on your installation: list and download files, upload new ones,
share them with clients, and manage client accounts and groups.
Everything here describes **v1**, mounted at `/api/v1`. The machine-readable specification is at
`GET /api/v1/openapi.json` and can be imported straight into Postman, Insomnia or a code generator.
---
## Getting started
1. Sign in and go to **Settings → API tokens**.
2. Create a token, tick only the permissions the tool needs, and give it an expiry.
3. Copy the token immediately — it is shown once and never again. Only a hash is stored.
Then:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json" \
https://your-install.example.com/api/v1/me
```
`GET /api/v1/me` is the first call worth making. It tells you who the token belongs to, which
abilities it *effectively* has, which edition this installation runs, and which optional modules are
available.
---
## Authentication
Bearer tokens only:
```
Authorization: Bearer 1|psend_xxxxxxxxxxxxxxxxxxxx
```
There is no cookie or session authentication on `/api/*`, and no password-login endpoint. Tokens are
created in the web interface, by a person who has already signed in and confirmed their password.
That is a deliberate limitation: it keeps passwords off the API entirely.
**Staff accounts only.** Client accounts cannot hold a working token in v1.
Three things must hold for a call to succeed, on every request:
| | |
|---|---|
| The token carries the ability | Chosen when the token was created or last edited |
| The owner still holds the permission | Re-checked live — a demoted account's token loses the ability immediately, without anyone revoking it |
| This edition has the feature | Community-only features are unavailable on cloud installs and vice versa |
A token can therefore never do more than the person who created it, and never more than it could on
the day it was minted.
### Expiry and revocation
Every token has an expiry (default 90 days, maximum 365). "Never expires" exists but is off by
default — a token nobody ever rotates is a password nobody ever changes.
Revoke from the settings page, or let a token retire itself:
```bash
curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \
https://your-install.example.com/api/v1/tokens/current
```
That revokes only the calling token. Revoking someone else's is deliberately a web-only action.
---
## Abilities
An ability is one of the installation's permission keys. The token creation screen lists only the
ones you hold *and* that the API can currently act on, so what you see there is the authoritative
list for your account.
| Ability | Unlocks |
|---|---|
| `upload` | list files, upload |
| `edit_files` / `edit_others_files` | read and edit file metadata, share files |
| `delete_files` / `delete_others_files` | delete files |
| `set_file_expiration_date` | set `expires_at` when editing |
| `set_file_categories` | set `categories` when editing |
| `limit_downloads` | set `download_limit` and `download_limit_scope` when editing |
| `upload_public` | set `public` when editing |
| `upload` / `edit_files` / `edit_others_files` | read and write a file's comments |
| `manage_clients` | list clients |
| `create_clients` / `edit_clients` / `delete_clients` | create, read and edit, delete clients; `edit_clients` also removes a client's two-factor authentication |
| `manage_groups` | list groups |
| `moderate_comments` | list what is awaiting approval, and approve it |
| `manage_users` | list staff accounts and the roles you may assign |
| `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also removes an account's two-factor authentication |
There is no ability for *writing* a comment. Who may comment is an installation setting rather than
a per-role permission, so the file abilities are the gate — the same question the web asks, which is
"can you see this file". Removing somebody *else's* comment sits there too, because the same
endpoint also lets an author remove their own within the editing window and that is not moderation;
it additionally requires the token's owner to hold `moderate_comments`, checked live against the
account rather than carried by the token.
| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups |
Where an endpoint accepts several — `edit_files` *or* `edit_others_files` — holding either is enough,
and which one applies to a given file depends on whether you uploaded it.
Every operation in the OpenAPI document names its own requirement.
---
## Responses
Successful responses wrap the payload in `data`:
```json
{ "data": { "id": 12, "name": "Quarterly report" } }
```
List endpoints add cursor pagination:
```json
{ "data": [ ], "links": { "next": "…?cursor=eyJ…" }, "meta": { } }
```
### Errors
Errors are [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) problem documents, served as
`application/problem+json`:
```json
{
"type": "validation_failed",
"title": "The given data was invalid.",
"status": 422,
"errors": { "name": ["The name field is required."] }
}
```
`type` is a stable slug you can branch on. `title` and `detail` are prose and may be reworded.
| Status | `type` | Means |
|---|---|---|
| 401 | `unauthenticated` | Missing, malformed, expired or revoked token |
| 403 | `forbidden` | The token, the account, or the edition lacks what this call needs |
| 404 | `not_found` | No such resource, or none you may see |
| 413 | `payload_too_large` | Upload part over the size limit |
| 422 | `validation_failed` | See `errors` |
| 429 | `too_many_requests` | Slow down; honour `Retry-After` |
---
## Rate limits
Limits are per token, so one integration cannot exhaust another's allowance. Every response carries:
```
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
```
and a `Retry-After` header on 429. Uploads have their own, tighter bucket.
> **Self-hosting behind a proxy?** Set `TRUSTED_PROXIES`. Without it every request appears to come
> from the proxy, so unauthenticated rate limits collapse into one shared bucket and the download IP
> log records the proxy instead of the caller.
---
## Reading a list, and polling for changes
All list endpoints accept `per_page` (capped) and `cursor`. Pass the `links.next` URL back verbatim
to walk forward.
To watch for changes — the usual reason an automation tool calls an API on a timer — pass
`updated_since`:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-install.example.com/api/v1/files?updated_since=2026-08-06T09:00:00Z"
```
With `updated_since` the walk is ordered oldest-first by last-modified time, so new and edited rows
always arrive at the end and paging forward visits every row exactly once. Keep the highest
`updated_at` you have seen and pass it on the next poll.
The boundary is inclusive, so you will occasionally re-see the row exactly on your watermark;
de-duplicate by `id`. That is the safe direction of the trade — excluding it could drop a row that
shares a timestamp with another.
**Polling cannot see deletions.** A deleted row simply stops appearing. If you need to react to
deletions, that is what webhooks will be for; they are not built yet.
---
## Reacting to things that happen
Every list above answers "what is there now". `GET /api/v1/activity` answers "what happened", which
is what an automation tool actually needs — and for two of the most useful events it is the only
place to look.
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-install.example.com/api/v1/activity?action[]=file.assigned"
```
```json
{
"data": [
{
"id": 1284,
"action": "file.assigned",
"created_at": "2026-08-25T09:14:02+00:00",
"actor": { "id": 3, "name": "Dana", "type": "staff" },
"origin": "ui",
"subject": { "type": "file", "id": 128, "name": "October invoice" },
"context": { "target": "Acme Ltd" }
}
]
}
```
**Sharing a file leaves no mark on the file.** It writes an assignment, and the file's own
`updated_at` does not move — so polling `/files?updated_since=` will never show you a share, no
matter how often you ask. The same goes for downloads, which are recorded here and nowhere else.
Repeat `action` for more than one: `?action[]=file.assigned&action[]=file.downloaded`. An action
this installation has never heard of is a `422` rather than being quietly dropped, because ignoring
it would hand back the whole log to a caller who asked for one slice. `subject_type` narrows to one
kind of thing — `file`, `folder`, `user`, `group`, `category`, `role`.
Polling works as it does everywhere else. Entries are never edited, so `updated_since` walks the
moment each was recorded; the two mean the same thing on a log that is only ever appended to.
Needs `view_actions_log`, the same permission the activity screen uses, and the same scoping: a
staff member limited to their assigned clients sees their own library and their own actions, never
the whole installation's.
**No IP addresses.** Some entries record one, and the activity screen shows it. It is left out here
on purpose: handing a client's IP to an automation tool is a privacy question nobody asked to have
answered for them.
**Deletions work here too**, which is the one thing polling a list can never do. A deleted file
stops being returned by `/files` and nothing marks the moment it went; the log records it as an
event like any other, so `?action[]=file.deleted` tells you.
One shape to know for those: a deletion entry has **no subject**. By the time it is written the row
is gone, so what the thing was called is snapshotted into `context.name` instead. Read that rather
than `subject.name` when you are reacting to something being removed.
What this still cannot tell you is anything the log does not record, which is deliberately less than
everything.
---
## Uploading
Two ways, and the right one depends on the file.
### One request
```bash
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@report.pdf" \
-F "name=Quarterly report" \
https://your-install.example.com/api/v1/files
```
Simple, and fine up to this installation's configured maximum upload size. There is no resume: a
dropped connection means starting again.
The stored content type is detected from the bytes, not from what you declare.
### Resumable, in parts
For large files or unreliable connections. Four steps:
1. `POST /uploads` with `filename` and `size` → returns an `uploadId`.
2. For each part, `GET /uploads/{id}/parts/{n}/sign` → returns a short-lived signed URL; `PUT` the
part's bytes to it. Parts may go in any order, and `GET /uploads/{id}/parts` lists what has
arrived, so an interrupted upload resumes rather than restarts.
3. `POST /uploads/{id}/complete` → assembles the parts and returns the created file.
4. `DELETE /uploads/{id}` abandons an upload you no longer want.
The size you declare in step 1 is checked again against the assembled bytes at step 3, so it is a
courtesy, not a promise the server trusts.
---
## Sharing a file
Sharing is a file plus a target:
```bash
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"client","id":42}' \
https://your-install.example.com/api/v1/files/12/assignments
```
`type` is `client` or `group`. This is idempotent — assigning twice changes nothing — so retrying a
request that may already have succeeded is safe.
It also does everything the web interface does: the recipient gets an in-app notification, and the
share email follows on the usual delay.
`DELETE` with the same body revokes the share.
### Files that are new versions of other files
A file can be marked as a new version of an earlier one:
```bash
curl -X PUT -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"previous_file_id":11}' \
https://your-install.example.com/api/v1/files/12/version
```
**A new version is shared with exactly the people the original is shared with.** It has no
recipients of its own, so assigning it returns `422` and names the file to assign instead — every
version follows that one. Any recipients the file already had are moved onto the original when you
link it, so nobody loses access.
`DELETE /files/12/version` removes the link. The file then keeps a copy of the recipients it was
inheriting, so unlinking does not revoke anything either.
Every file carries `is_revision`, `sharing_root_id`, `previous_version` and `next_version`. The last
two are narrowed to what your token may see: a counterpart outside your reach reads as `null`.
---
## Staff accounts
`/users` manages the people who administer the installation, and the role assigned to each of them.
Clients are a different population with their own `/clients` endpoints and never appear here.
Available on every edition. A managed installation may cap how many staff accounts exist — the
operator supplies the number, and creating one past it is refused with a validation error naming the
limit — but who fills those seats, and which role each of them holds, is the installation's own
decision and always was.
Two abilities are needed for each call: `manage_users` to reach the area at all, then the one for the
action (`create_users`, `edit_users`, `delete_users`). That mirrors the web UI, where the whole
section sits behind `manage_users` and each button behind its own key.
### Two rules that will refuse you
**You cannot hand out authority you do not hold.** `role_id` must name a role you could grant
yourself: a caller who is not an administrator may not create one, nor assign any role carrying a
permission they lack, nor touch an account whose role already outranks them (that one is a `403`).
`GET /roles` lists exactly what is available to you, so the safe move is to read it rather than
guess an id.
**The installation always keeps an active administrator.** Demoting, deactivating or deleting the
last one is a `422`. So is deactivating or deleting yourself, from either surface.
### Changing a role
The assigned role is a field on the account, so `PATCH /users/{user}` with `role_id` is the whole
operation. `assigned_clients` only means anything for a `client_scoped` role; moving to any other
role clears it, whether or not you mention it.
Creating and deleting *roles themselves* is deliberately not on the API: a role is a
security boundary, and changing one is a deliberate act performed in the UI.
## Deleting a client
If the client owns files or folders, you must say what happens to them:
```bash
curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content_action":"reassign","reassign_to_id":3}' \
https://your-install.example.com/api/v1/clients/42
```
`content_action` is `cascade_delete` or `reassign`. There is no default: one would silently destroy
the files, the other would silently hand them to somebody else. `GET /clients/{id}` reports the
counts so you can decide first.
A client owning nothing deletes with no body at all.
---
## Unlocking an account whose second factor is lost
Two-factor authentication has a failure mode that nothing else in the API does: when the
authenticator app and the recovery codes are both gone, the account cannot be opened by its holder
*or* by anybody else. `DELETE` the second factor to put the account back to password-only sign-in:
```bash
curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \
https://your-install.example.com/api/v1/clients/42/two-factor
curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \
https://your-install.example.com/api/v1/users/7/two-factor
```
`edit_clients` and `edit_users` respectively — this changes how an account signs in, it does not
remove one, so it is not the `delete_*` key. Both answer `204` whether or not a second factor was
actually in force, and the staff route additionally refuses (`403`) an account whose role outranks
the caller's, exactly like `PATCH` does.
Two things always happen, and neither is optional: the account holder is emailed that it happened,
and the call is recorded in the activity log against the token's owner. If the installation enforces
two-factor authentication for that population, the account is asked to enrol again on its next
sign-in — this un-sticks an account, it does not exempt one.
`two_factor_enabled` on `GET /clients`, `GET /clients/{client}`, `GET /users` and
`GET /users/{user}` tells you whether there is anything to remove.
---
## Retries and duplicate requests
Assignments and group membership are idempotent. **Creating a file or a client is not** — a retried
`POST` that actually succeeded the first time creates a second one. Until idempotency keys exist,
check before retrying a create you are unsure about.
---
## Module endpoints
Some installations carry optional modules that add their own endpoints under
`/api/v1/modules/{module}/…`. `GET /api/v1/me` lists which are present, so a tool can adapt rather
than guess.
Everything documented above — authentication, abilities, error format, rate limits — applies to them
unchanged; the core supplies all of it rather than each module restating it.
### For module authors
Register from your package's service provider, listening by string class name so your package stays
buildable without the host application present:
```php
Event::listen('App\Modules\Api\Events\RegisteringApiModules', function ($event): void {
$event->register(
slug: 'branding',
routes: __DIR__.'/api-routes.php',
capability: 'branding.customize',
);
});
```
The core supplies the prefix, the route-name namespace, the authentication stack and the capability
gate. Your routes declare paths, controllers, and a `token-can:` middleware naming permissions from
the core vocabulary. A slug must be unique and lowercase; a clash throws at boot rather than
silently shadowing.
Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips
`api/v1/modules/*` — because that document is served unauthenticated and has to be identical on
every installation. The modules that ship with the application document their endpoints in
[`api-modules.md`](api-modules.md); a module living in its own package documents its surface in its
own repository.
---
## Versioning
`/api/v1` is a frozen contract. Additive changes only: new endpoints, new optional parameters, new
response fields. Removing or renaming a field, tightening validation, or changing a status code is
breaking and would arrive as `/api/v2`. Anything scheduled for removal ships `Deprecation` and
`Sunset` headers first.
`docs/api-changelog.md` records every change.
---
## Not in v1
Recorded so they read as decisions rather than gaps:
- **Client-user tokens.** Only staff accounts can hold one.
- **Password login.** Tokens come from the web interface.
- **Webhooks.** Poll instead; see above.
- **Idempotency keys.** See "Retries" above.
- **Share links, notifications, thumbnails, settings.**
- **Creating and deleting roles.** `GET /roles` reads them and `role_id` assigns one; defining a
role's permission set stays in the UI.
---
## Keeping this document true
Anyone shipping a feature or a fix — API-related or not — should run through
asking whether the change needs an API change at all.
For anyone adding an endpoint:
1. Route in `routes/api.php` inside the auth group, with a `token-can:` naming the same permission
its `routes/web.php` twin uses.
2. A `JsonResource` with an explicit field allowlist — never `$model->toArray()`.
3. Authorization through the domain's existing policy or scope. The token ability is an *additional*
gate, never a replacement.
4. A feature test, including the negative case for a client-scoped staff token.
5. Rerun `php artisan scramble:export`, add a row above if it changes the ability table, and add a
changelog entry.
The token creation screen needs no update — it derives its list by scanning routes for `token-can:`.
Note that **docblocks on controller methods under an `Api` namespace are published as the reference**.
Write them for the reader; keep implementation notes in `//` comments inside the method.