mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
ddf09677f0
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.
504 lines
20 KiB
Markdown
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.
|