openapi: 3.0.3 info: title: Pireon Admin API version: "1.0.0" description: | REST API of the admin application. ## Two APIs in one document - **`/public/v1/*`** — unauthenticated, read-only. Consumed by the public frontend, which renders server-side. Everything it returns is already published on the public website. - **`/v1/*`** — authenticated administration, described further down. ## Authentication Every endpoint under `/v1` except `POST /v1/auth/token` requires a bearer token issued by the **Pireon identity server** (`https://oauth.pireon.dev`), which this application validates — or a **machine token** of its own. Obtain a token through `POST /v1/auth/token`, which proxies the identity server's password grant. The panel's own sign-in form uses the same grant, so `PIREON_PASSWORD_GRANT=false` closes both. Authorization Code + PKCE against `https://oauth.pireon.dev/oauth/authorize` remains available for clients that prefer never to handle a password. Machine tokens (`pmt_…`) are for scripts and integrations: a person makes one with `POST /v1/tokens`, naming the permissions it gets — never more than their own — and when it expires. It acts as that person within those permissions, checked on every request, so taking a role away from them narrows their tokens at once. It is checked here and never sent to the identity server. ## Authorisation The identity server establishes *who* the caller is. What they may *do* is decided here, by the roles and permissions administered in this app's panel. `GET /v1/me` returns the caller's effective permissions; drive your UI from that array rather than from role names. ## Languages Content fields (`title`, `slug`, `excerpt`, `body`, …) are translatable. By default they are returned resolved to a single language, chosen as: 1. `?lang=xx` 2. the user's stored preference 3. the `Accept-Language` header 4. the application default (`es`) Pass `?with=translations` to receive every language alongside, and send a `{"es": "...", "ca": "..."}` object to write specific languages. A write that names only some languages leaves the others untouched. servers: - url: /api description: API base URL tags: - name: Public description: | Unauthenticated, read-only endpoints for the public frontend. Records are addressed **by slug within the active locale**, because that is the access pattern a server-rendered frontend has: it receives a URL, not an id. A slug only resolves in the language it belongs to, so one record never answers on two addresses. Every single-record response carries `alternates` — the same record's path in each language that actually has a translation. Only this API knows that `/proyectos/mi-web` and `/ca/projectes/el-meu-web` are one thing, so without it the frontend cannot emit correct `hreflang` tags. Locales with no translation are omitted rather than pointing at a URL that would 404. Responses carry `ETag`, `Cache-Control: s-maxage`, `stale-while-revalidate` and `Vary: Accept-Language`. Send `If-None-Match` to revalidate cheaply, and watch `content_version` from `/site` to know when a cached copy is genuinely out of date. Unpublished records can be reached two ways: the shared preview secret (`X-Preview-Token`), which unlocks everything, or a signed link generated in the panel for one record (`?preview=…`), which expires and unlocks only that one. - name: Submissions description: | The public API's only write endpoints: the two forms the site offers. They carry their own throttle, a honeypot and an off switch, because they are the one place a stranger can put a row in the database. What arrives lands in the panel's inbox, and the people who can read it are notified. - name: Telemetry description: | The frontend reporting back, rather than asking for something. Also a write endpoint, but the author is the frontend itself and not a visitor, so unlike the submissions it holds a shared secret (`X-Ingest-Token`) instead of a honeypot. An unset secret disables the endpoint; it never opens it. Two things arrive here: addresses that failed, which are the raw material of a redirect, and records that were read, which is the only way this side can know whether anything it publishes is opened. Neither carries anything about a person — no address, no session, no user agent, no time of day. - name: Auth description: Token exchange and caller identity - name: Pages description: Translatable page content - name: Categories description: Translatable page categories - name: Projects description: The collective's work — translatable, with its team and stack - name: Members description: The people in the collective - name: Services description: What the collective offers - name: Clients description: The organisations the work was for - name: Testimonials description: What the people the collective worked with said - name: Talks description: Talks given elsewhere, by members of the collective - name: Calls description: Open calls for contributors - name: Applications description: The inbox — what people sent from the public site - name: Technologies description: What the collective uses, and its place on the tech radar - name: Redirects description: Old addresses, and the missing ones the frontend reported - name: Repositories description: The collective's git repositories and their synced numbers - name: Settings description: Runtime settings and the public site's maintenance mode - name: History description: Who changed what and when, and undoing a change - name: Reviews description: Review, publication and preview links - name: Health description: What is wrong with the site, and what is left to translate - name: Batch description: Several operations in one, and importing records from JSON or CSV - name: Webhooks description: Telling other systems what happened here - name: Users description: Local user administration - name: Media description: The media library, and images uploaded for a record to point at - name: Roles description: Local authorisation model (read-only) security: - bearerAuth: [] paths: /public/v1/site: get: tags: [Public] summary: Locales and headline numbers description: What the frontend needs once on any page. security: [] responses: "200": description: Site metadata content: application/json: schema: type: object properties: data: type: object properties: locales: type: array items: type: object properties: code: { type: string, example: ca } name: { type: string, example: Català } flag: { type: string } is_default: { type: boolean } default_locale: { type: string, example: es } content_version: type: integer description: | Moves whenever published content does. Keep it as part of your cache key and everything downstream goes stale the moment the site really changed, rather than when a timer says so. submissions_enabled: type: boolean description: | Whether the forms are accepting messages. Hide them when false rather than rendering one that would be refused on submit. stats: type: object properties: projects: { type: integer } active_projects: { type: integer } members: { type: integer } open_calls: { type: integer } good_first_issues: { type: integer } stars: { type: integer } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/slugs: get: tags: [Public] summary: Every public URL, grouped by type and locale description: | One request instead of paging through every collection — this is what the frontend builds its sitemap and route table from. security: [] responses: "200": description: All published URLs content: application/json: schema: type: object properties: data: type: object additionalProperties: type: array items: type: object properties: id: { type: integer } updated_at: { type: string, format: date-time } alternates: type: object additionalProperties: { type: string } example: es: /proyectos/mi-web ca: /ca/projectes/el-meu-web "503": { $ref: "#/components/responses/Maintenance" } /public/v1/redirects: get: tags: [Public] summary: URLs that used to work description: | Every enabled redirect, so the frontend can answer a 301 instead of a 404. Most were written by the panel itself the moment somebody renamed a slug — the only moment the old address was still known. Returned whole rather than looked up one at a time: there are never many, and an SSR frontend would otherwise call this API on every 404 it serves. security: [] responses: "200": description: Enabled redirects content: application/json: schema: type: object properties: data: type: array items: type: object properties: from: { type: string, example: /proyectos/nombre-viejo } to: { type: string, example: /proyectos/nombre-nuevo } status: { type: integer, enum: [301, 302] } locale: type: string nullable: true description: Null applies in every language. "503": { $ref: "#/components/responses/Maintenance" } /public/v1/search: get: tags: [Public] summary: Search the site description: | What the site shows that matches some words, in the request's language, best match first: published pages, projects and services, open calls, public members, and technologies in use. Full-text in production — words are stemmed in the language searched, so "proyectos" finds "proyecto", and a title match ranks above one in the body. `path` is where the result lives on the site; null for a technology, which has no address on this side. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: q in: query required: true schema: { type: string, minLength: 2, maxLength: 120 } - name: type[] in: query description: "Limit to some kinds: `page`, `project`, `service`, `call`, `member`, `technology`." schema: type: array items: { type: string, enum: [page, project, service, call, member, technology] } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 50, default: 20 } responses: "200": description: Results content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicSearchResult" } meta: type: object properties: query: { type: string } locale: { type: string } count: { type: integer } "422": { $ref: "#/components/responses/ValidationError" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/projects: get: tags: [Public] summary: List published projects security: [] parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PerPage" - name: status in: query schema: { type: string, enum: [idea, active, paused, completed, archived] } - name: featured in: query schema: { type: boolean } - name: needs_help in: query description: Only projects looking for contributors. schema: { type: boolean } - name: technology in: query description: Technology slug; repeat for several. schema: { type: string } - name: member in: query schema: { type: string } - name: translated in: query description: Exclude projects with no translation in the active locale. schema: { type: boolean } responses: "200": description: Paginated projects content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicProject" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/projects/{slug}: get: tags: [Public] summary: A single project, with its write-up description: | Adds the long-form fields the listing omits: `description`, `decisions` and `postmortem`, plus members, open calls and live repository stats. security: [] parameters: - name: slug in: path required: true description: The slug in the active locale. schema: { type: string } - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PreviewToken" responses: "200": description: The project content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicProject" } "404": { description: No project with that slug in that language } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/members: get: tags: [Public] summary: List public members security: [] parameters: - $ref: "#/components/parameters/Lang" - name: include_past in: query description: Include people who have left, for an alumni section. schema: { type: boolean } - name: technology in: query schema: { type: string } responses: "200": description: Paginated members content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicMember" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/members/{slug}: get: tags: [Public] summary: A single member, with their projects security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" responses: "200": description: The member content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicMember" } "404": { description: Not found } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/calls: get: tags: [Public] summary: Open calls for contributors description: | Excludes closed calls, calls outside their window, and calls attached to an unpublished project. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: technology in: query schema: { type: string } - name: max_hours_per_week in: query description: '"I have four hours a week" — the filter people actually apply.' schema: { type: integer } responses: "200": description: Paginated calls content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicCall" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/calls/{slug}: get: tags: [Public] summary: A single open call security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" responses: "200": description: The call content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicCall" } "404": { description: Not found or no longer open } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/calls/{slug}/apply: post: tags: [Submissions] summary: Answer a call description: | The public API's only write endpoints are this one and `/public/v1/contact`. Only an **open** call accepts applications; anything else answers 404, because letting someone write a message for a position that is already filled wastes their time. Submitting the same address to the same call twice inside the deduplication window (24 h by default) answers **200** with `duplicate: true` and stores nothing further: a double-clicked submit button is not a second person. Throttling is per e-mail address plus a site-wide hourly ceiling — deliberately not per IP, since with an SSR frontend every submission arrives from the same address. security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/Submission" } responses: "201": { $ref: "#/components/responses/SubmissionAccepted" } "200": description: Recognised as a repeat of a submission already stored content: application/json: schema: { $ref: "#/components/schemas/SubmissionReceipt" } "404": { description: No open call with that slug in this language } "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many submissions from this address } "503": description: | The public site is in maintenance, or submissions are switched off (`SITE_ACCEPT_SUBMISSIONS=false`). The frontend can tell the two apart by the `maintenance` and `submissions_enabled` keys. /public/v1/contact: post: tags: [Submissions] summary: Write in without a specific call description: | The general form: somebody offering time with no call in mind, or an association asking for help. `kind` chooses between the two, and `organisation` becomes required when it is `request`. security: [] parameters: - $ref: "#/components/parameters/Lang" requestBody: required: true content: application/json: schema: allOf: - $ref: "#/components/schemas/Submission" - type: object properties: kind: type: string enum: [contribute, request] default: contribute responses: "201": { $ref: "#/components/responses/SubmissionAccepted" } "200": description: Recognised as a repeat of a submission already stored content: application/json: schema: { $ref: "#/components/schemas/SubmissionReceipt" } "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many submissions from this address } "503": { description: "Maintenance, or submissions switched off" } /public/v1/telemetry/not-found: post: tags: [Telemetry] summary: Report addresses the frontend could not resolve description: | Redirects were only ever written from this side: renaming a slug leaves a rule behind, because that is the one moment the old address is still known. Every other broken link — the previous website's, one printed on paper, another site linking a page that moved years ago — fails on the frontend, in front of a visitor, and never reaches the panel. This is where the frontend says so. Reports land in **Administration → Missing URLs**, where one becomes a redirect with a single action. Accepts either a single report or a batch of up to `SITE_TELEMETRY_MAX_BATCH` (50 by default). Paths are normalised exactly as redirects are — trailing slashes and query strings are stripped — and stored as one row per `(path, locale)` with a hit count, so a crawler costs one row rather than one per request. Obvious scans (`/wp-admin`, `/.env`, anything ending in `.php`) are dropped rather than stored. The response says how many of the reports were kept, so the frontend can tell its filter from this one. Not affected by maintenance mode: a 404 during a deploy is still a 404, and those are the most interesting ones. security: - ingestToken: [] parameters: - $ref: "#/components/parameters/IngestToken" requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/MissingUrlReport" - type: object required: [hits] properties: hits: type: array minItems: 1 maxItems: 50 items: { $ref: "#/components/schemas/MissingUrlReport" } responses: "202": description: | Reports accepted. Nothing here is a resource the caller owns, and a report merged into an existing count or dropped as noise is still successful — hence 202 rather than 201. content: application/json: schema: type: object properties: data: type: object properties: received: { type: integer, example: 3 } recorded: { type: integer, example: 1 } "403": description: | Missing or wrong `X-Ingest-Token`, or reporting is disabled because `SITE_INGEST_TOKEN` is unset. "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many reports from this address } /public/v1/telemetry/views: post: tags: [Telemetry] summary: Report what was read description: | The panel can say what exists and what is published. It has never been able to say whether any of it gets opened — which is the fact behind most editorial decisions: what to translate next, whether the featured project is one anybody looks at, whether a page earns the work of keeping it accurate. Reports are addressed the way the frontend already thinks: it fetched `/public/v1/projects/mi-web` to draw the page, so it reports a **type** and a **slug** rather than parsing its own URL back apart. A slug that no longer resolves is not counted — that is a 404, and `/telemetry/not-found` is where those belong. **There is no visitor in this.** No address, no session, no user agent, no referrer, no time of day. What is stored is one row per record, per language, per day, holding a number — enough for the questions above and not enough to reconstruct anybody. Reading figures are kept for 400 days and then pruned. Accepts a single report or a batch of up to `SITE_TELEMETRY_MAX_BATCH`. security: - ingestToken: [] parameters: - $ref: "#/components/parameters/IngestToken" requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/ViewReport" - type: object required: [views] properties: views: type: array minItems: 1 maxItems: 50 items: { $ref: "#/components/schemas/ViewReport" } responses: "202": description: | Reports accepted. `recorded` counts the ones that named something this API still serves. content: application/json: schema: type: object properties: data: type: object properties: received: { type: integer, example: 2 } recorded: { type: integer, example: 2 } "403": description: | Missing or wrong `X-Ingest-Token`, or reporting is disabled because `SITE_INGEST_TOKEN` is unset. "422": { $ref: "#/components/responses/ValidationError" } "429": { description: Too many reports from this address } /public/v1/services: get: tags: [Public] summary: What the collective offers description: | The counterpart to a project: one says what has been built, this says what somebody would be asking for. Ordered as arranged in the panel. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: featured in: query schema: { type: boolean } responses: "200": description: Published services content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicService" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/services/{slug}: get: tags: [Public] summary: A single service description: | Carries the work that backs the offer and what people said about it — a service page with neither is a promise. security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" responses: "200": description: The service content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicService" } "404": { description: "Not found in this language, or not published" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/clients: get: tags: [Public] summary: The wall of logos description: | Organisations the work was for. Anyone who asked not to appear is absent whatever else is true of them, and by default so is anyone with no published project behind them — a logo the site could not justify showing. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: with_projects in: query description: Set to 0 to include clients with nothing published yet. schema: { type: boolean, default: true } responses: "200": description: Public clients content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicClient" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/testimonials: get: tags: [Public] summary: What other people said description: | Attached to a client, a project or a service rather than to a page, so ask for the ones belonging to whatever you are rendering. security: [] parameters: - $ref: "#/components/parameters/Lang" - name: client in: query description: Client slug. schema: { type: string } - name: service in: query description: Service slug, in the active language. schema: { type: string } responses: "200": description: Public testimonials content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTestimonial" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/talks: get: tags: [Public] summary: Talks given elsewhere security: [] parameters: - $ref: "#/components/parameters/Lang" - name: with_video in: query schema: { type: boolean } - name: limit in: query schema: { type: integer, default: 50, maximum: 100 } responses: "200": description: Public talks, newest first content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTalk" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/pages: get: tags: [Public] summary: List published pages security: [] parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PerPage" - name: category in: query schema: { type: string } - name: featured in: query schema: { type: boolean } responses: "200": description: Paginated pages content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicPage" } meta: { $ref: "#/components/schemas/PaginationMeta" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/pages/{slug}: get: tags: [Public] summary: A single published page security: [] parameters: - name: slug in: path required: true schema: { type: string } - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/PreviewToken" responses: "200": description: The page content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PublicPage" } "404": { description: Not found } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/technologies: get: tags: [Public] summary: Technologies in use security: [] parameters: - $ref: "#/components/parameters/Lang" - name: category in: query schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } - name: in_use in: query description: Defaults to true — only technologies attached to a project. schema: { type: boolean } responses: "200": description: Technologies content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTechnology" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/radar: get: tags: [Public] summary: The tech radar description: | Technologies placed on a ring — adopt, trial, assess or hold — with the note explaining why. Technologies with no ring are omitted. security: [] parameters: - $ref: "#/components/parameters/Lang" responses: "200": description: Radar entries content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicTechnology" } "503": { $ref: "#/components/responses/Maintenance" } /public/v1/good-first-issues: get: tags: [Public] summary: Newcomer-friendly open issues across all repositories description: | Mirrored from the git provider and refreshed hourly. Claimed issues, archived repositories and repositories hidden from the site are excluded — an issue somebody already took is not an invitation. security: [] parameters: - name: limit in: query schema: { type: integer, default: 20, maximum: 100 } - name: language in: query description: Filter by the repository's primary language. schema: { type: string, example: PHP } - name: include_help_wanted in: query schema: { type: boolean, default: true } responses: "200": description: Open issues content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PublicIssue" } "503": { $ref: "#/components/responses/Maintenance" } /v1/auth/token: post: tags: [Auth] summary: Exchange credentials or a refresh token for an access token description: | Proxies the identity server's `password` and `refresh_token` grants so first-party clients need not embed the OAuth server's URL or secret. Rate limited to 6 requests per minute per account and address, and 30 per address overall. Browser clients should use Authorization Code + PKCE instead. security: [] requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/PasswordGrantRequest" - $ref: "#/components/schemas/RefreshGrantRequest" responses: "200": description: Token issued content: application/json: schema: { $ref: "#/components/schemas/TokenResponse" } "401": { description: Credentials rejected by the identity server } "403": { description: Account inactive or awaiting approval } "422": { $ref: "#/components/responses/ValidationError" } /v1/me: get: tags: [Auth] summary: The current caller, with their effective local permissions responses: "200": description: The authenticated user content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } /v1/tokens: get: tags: [Auth] summary: List my machine tokens description: Newest first, revoked and expired ones included. A super admin may ask for everyone's with `all`. Not available to a machine token. parameters: - $ref: "#/components/parameters/PerPage" - name: all in: query description: "Everyone's tokens. Super admins only." schema: { type: boolean } responses: "200": description: Paginated tokens content: application/json: schema: { $ref: "#/components/schemas/ApiTokenCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Auth] summary: Make a machine token description: | `abilities` are permission names — `pages.update` — or a whole resource — `pages.*` — and only ones the caller holds. It expires after 90 days unless `expires_at` says otherwise, a year at most. The token itself is in the answer's `token`, and nowhere else ever again. Not available to a machine token. requestBody: required: true content: application/json: schema: type: object required: [name, abilities] properties: name: { type: string, maxLength: 120, example: Despliegue continuo } abilities: { type: array, items: { type: string }, example: ["technologies.*", "media.view_any"] } expires_at: { type: string, format: date-time } responses: "201": description: Token made content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ApiToken" } token: { type: string, description: The token. Shown once. } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/tokens/{token}: delete: tags: [Auth] summary: Revoke a machine token description: It stops working at once and stays listed as revoked. Your own, or anyone's for a super admin. parameters: - name: token in: path required: true schema: { type: integer } responses: "204": { description: Revoked } "403": { $ref: "#/components/responses/Forbidden" } /v1/pages: get: tags: [Pages] summary: List pages description: | Callers without the `pages.publish` permission only ever see published pages. parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: status in: query schema: { type: string, enum: [draft, review, published, archived] } - name: category_id in: query schema: { type: integer } - name: is_featured in: query schema: { type: boolean } - name: search in: query description: Matches title and excerpt within the active language only. schema: { type: string } - name: translated_in in: query description: Only pages that actually carry a translation for this locale. schema: { type: string, enum: [es, en, ca] } - name: trashed in: query schema: { type: boolean } - name: sort in: query description: 'Prefix with "-" for descending, e.g. `-published_at`.' schema: type: string enum: [title, created_at, updated_at, published_at, status, -title, -created_at, -updated_at, -published_at, -status] responses: "200": description: Paginated pages content: application/json: schema: { $ref: "#/components/schemas/PageCollection" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Pages] summary: Create a page description: > Requires `pages.create`. Setting `status` to `published` additionally requires `pages.publish`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PageWrite" } responses: "201": description: Page created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}: parameters: - name: page in: path required: true schema: { type: integer } get: tags: [Pages] summary: Show a page parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The page content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Pages] summary: Update a page description: | Partial update. Translatable fields merge into the existing map, so sending only `{"title": {"ca": "..."}}` leaves Spanish and English intact. Authors may always edit their own unpublished pages. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PageWrite" } responses: "200": description: Page updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Pages] summary: Soft-delete a page responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/pages/{page}/restore: post: tags: [Pages] summary: Restore a soft-deleted page parameters: - name: page in: path required: true schema: { type: integer } responses: "200": description: Page restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Page" } "403": { $ref: "#/components/responses/Forbidden" } /v1/categories: get: tags: [Categories] summary: List categories parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: is_visible in: query schema: { type: boolean } - name: search in: query schema: { type: string } responses: "200": description: Paginated categories content: application/json: schema: { $ref: "#/components/schemas/CategoryCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Categories] summary: Create a category requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CategoryWrite" } responses: "201": description: Category created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Category" } "422": { $ref: "#/components/responses/ValidationError" } /v1/categories/{category}: parameters: - name: category in: path required: true schema: { type: integer } get: tags: [Categories] summary: Show a category responses: "200": description: The category content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Category" } patch: tags: [Categories] summary: Update a category requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CategoryWrite" } responses: "200": description: Category updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Category" } delete: tags: [Categories] summary: Soft-delete a category description: Refused with 403 while the category still holds pages. responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/technologies: get: tags: [Technologies] summary: List technologies description: Ordered by `position`, then name. parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: category in: query schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } - name: radar_ring in: query description: '`none` for the technologies kept off the radar.' schema: { type: string, enum: [adopt, trial, assess, hold, none] } - name: search in: query description: Matches the name. schema: { type: string } responses: "200": description: Paginated technologies content: application/json: schema: { $ref: "#/components/schemas/TechnologyCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Technologies] summary: Create a technology description: | For a logo, upload it first with `POST /v1/media` and `for: technologies`, and send the `path` it answers with as `logo`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TechnologyWrite" } responses: "201": description: Technology created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Technology" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/technologies/{technology}: parameters: - name: technology in: path required: true schema: { type: integer } get: tags: [Technologies] summary: Show a technology parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The technology content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Technology" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Technologies] summary: Update a technology description: | Partial update. `description` and `radar_note` merge into the existing translations; send `radar_ring: null` to take it off the radar. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TechnologyWrite" } responses: "200": description: Technology updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Technology" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Technologies] summary: Delete a technology description: Refused with 403 while a project, member or call still uses it. responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/projects: get: tags: [Projects] summary: List projects parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: status in: query schema: { type: string, enum: [idea, active, paused, completed, archived] } - name: search in: query description: Matches the title within the active language only. schema: { type: string } - name: trashed in: query schema: { type: boolean } responses: "200": description: Paginated projects content: application/json: schema: { $ref: "#/components/schemas/ProjectCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Projects] summary: Create a project description: | Relations are sent as ids and replaced whole when present: `technology_ids`, `service_ids`, and `members` with each one's role. `published_at` and `unpublish_at` require `projects.publish`. For a cover, upload it with `POST /v1/media` and `for: projects`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProjectWrite" } responses: "201": description: Project created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Project" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/projects/{project}: parameters: - name: project in: path required: true schema: { type: integer } get: tags: [Projects] summary: Show a project parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The project content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Project" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Projects] summary: Update a project description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ProjectWrite" } responses: "200": description: Project updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Project" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Projects] summary: Soft-delete a project responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/projects/{project}/restore: post: tags: [Projects] summary: Restore a soft-deleted project parameters: - name: project in: path required: true schema: { type: integer } responses: "200": description: Project restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Project" } "403": { $ref: "#/components/responses/Forbidden" } /v1/members: get: tags: [Members] summary: List members parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: search in: query description: Matches the name. schema: { type: string } - name: is_public in: query schema: { type: boolean } - name: trashed in: query schema: { type: boolean } responses: "200": description: Paginated members content: application/json: schema: { $ref: "#/components/schemas/MemberCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Members] summary: Create a member description: | `links` is a label → URL map. `technology_ids` is replaced whole when present; which projects someone is on is set from the project, where the role belongs. For a photo, upload it with `POST /v1/media` and `for: members`. Someone may edit their own profile without `members.update`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/MemberWrite" } responses: "201": description: Member created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Member" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/members/{member}: parameters: - name: member in: path required: true schema: { type: integer } get: tags: [Members] summary: Show a member parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The member content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Member" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Members] summary: Update a member description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/MemberWrite" } responses: "200": description: Member updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Member" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Members] summary: Soft-delete a member responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/members/{member}/restore: post: tags: [Members] summary: Restore a soft-deleted member parameters: - name: member in: path required: true schema: { type: integer } responses: "200": description: Member restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Member" } "403": { $ref: "#/components/responses/Forbidden" } /v1/services: get: tags: [Services] summary: List services parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: is_published in: query schema: { type: boolean } - name: search in: query description: Matches the title within the active language only. schema: { type: string } - name: trashed in: query schema: { type: boolean } responses: "200": description: Paginated services content: application/json: schema: { $ref: "#/components/schemas/ServiceCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Services] summary: Create a service description: | `technology_ids` and `project_ids` are replaced whole when present. `is_published`, `published_at` and `unpublish_at` require `services.publish`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ServiceWrite" } responses: "201": description: Service created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Service" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/services/{service}: parameters: - name: service in: path required: true schema: { type: integer } get: tags: [Services] summary: Show a service parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The service content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Service" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Services] summary: Update a service description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ServiceWrite" } responses: "200": description: Service updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Service" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Services] summary: Soft-delete a service responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/services/{service}/restore: post: tags: [Services] summary: Restore a soft-deleted service parameters: - name: service in: path required: true schema: { type: integer } responses: "200": description: Service restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Service" } "403": { $ref: "#/components/responses/Forbidden" } /v1/clients: get: tags: [Clients] summary: List clients parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: is_public in: query schema: { type: boolean } - name: search in: query description: Matches the name. schema: { type: string } - name: trashed in: query schema: { type: boolean } responses: "200": description: Paginated clients content: application/json: schema: { $ref: "#/components/schemas/ClientCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Clients] summary: Create a client description: | Send the `path` from `POST /v1/media` with `for=clients` as `logo`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ClientWrite" } responses: "201": description: Client created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Client" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/clients/{client}: parameters: - name: client in: path required: true schema: { type: integer } get: tags: [Clients] summary: Show a client parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The client content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Client" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Clients] summary: Update a client description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ClientWrite" } responses: "200": description: Client updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Client" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Clients] summary: Soft-delete a client responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/clients/{client}/restore: post: tags: [Clients] summary: Restore a soft-deleted client parameters: - name: client in: path required: true schema: { type: integer } responses: "200": description: Client restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Client" } "403": { $ref: "#/components/responses/Forbidden" } /v1/testimonials: get: tags: [Testimonials] summary: List testimonials parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: is_public in: query schema: { type: boolean } - name: client_id in: query schema: { type: integer } - name: project_id in: query schema: { type: integer } - name: service_id in: query schema: { type: integer } - name: search in: query description: Matches the author's name. schema: { type: string } - name: trashed in: query schema: { type: boolean } responses: "200": description: Paginated testimonials content: application/json: schema: { $ref: "#/components/schemas/TestimonialCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Testimonials] summary: Create a testimonial description: | `quote` holds what the person said in `original_locale`, and any translations of it. Send the `path` from `POST /v1/media` with `for=testimonials` as `avatar`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TestimonialWrite" } responses: "201": description: Testimonial created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Testimonial" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/testimonials/{testimonial}: parameters: - name: testimonial in: path required: true schema: { type: integer } get: tags: [Testimonials] summary: Show a testimonial parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The testimonial content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Testimonial" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Testimonials] summary: Update a testimonial description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TestimonialWrite" } responses: "200": description: Testimonial updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Testimonial" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Testimonials] summary: Soft-delete a testimonial responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/testimonials/{testimonial}/restore: post: tags: [Testimonials] summary: Restore a soft-deleted testimonial parameters: - name: testimonial in: path required: true schema: { type: integer } responses: "200": description: Testimonial restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Testimonial" } "403": { $ref: "#/components/responses/Forbidden" } /v1/talks: get: tags: [Talks] summary: List talks parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: is_public in: query schema: { type: boolean } - name: member_id in: query schema: { type: integer } - name: project_id in: query schema: { type: integer } - name: search in: query description: Matches the title or the event. schema: { type: string } responses: "200": description: Paginated talks content: application/json: schema: { $ref: "#/components/schemas/TalkCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Talks] summary: Create a talk description: | `title` is the one the talk was given under, in `language`. Only `summary` is translated. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TalkWrite" } responses: "201": description: Talk created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Talk" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/talks/{talk}: parameters: - name: talk in: path required: true schema: { type: integer } get: tags: [Talks] summary: Show a talk parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The talk content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Talk" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Talks] summary: Update a talk description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TalkWrite" } responses: "200": description: Talk updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Talk" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Talks] summary: Delete a talk responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/calls: get: tags: [Calls] summary: List calls parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" - $ref: "#/components/parameters/PerPage" - name: status in: query description: "`open`, `filled` or `closed`." schema: { type: string } - name: project_id in: query schema: { type: integer } - name: search in: query description: Matches the title within the active language only. schema: { type: string } responses: "200": description: Paginated calls content: application/json: schema: { $ref: "#/components/schemas/CallCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Calls] summary: Create a call description: | `technology_ids` is replaced whole when present. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CallWrite" } responses: "201": description: Call created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Call" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/calls/{call}: parameters: - name: call in: path required: true schema: { type: integer } get: tags: [Calls] summary: Show a call parameters: - $ref: "#/components/parameters/Lang" - $ref: "#/components/parameters/With" responses: "200": description: The call content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Call" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Calls] summary: Update a call description: Partial update. Translatable fields merge into the existing map. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/CallWrite" } responses: "200": description: Call updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Call" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Calls] summary: Delete a call responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/applications: get: tags: [Applications] summary: List applications parameters: - $ref: "#/components/parameters/PerPage" - name: status in: query description: "`new`, `contacted`, `accepted`, `declined` or `spam`. Without it, spam is left out." schema: { type: string } - name: kind in: query description: "`contribute` or `request`." schema: { type: string } - name: call_id in: query schema: { type: integer } - name: search in: query description: Matches the name, e-mail or organisation. schema: { type: string } - name: trashed in: query schema: { type: boolean } responses: "200": description: Paginated applications content: application/json: schema: { $ref: "#/components/schemas/ApplicationCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/applications/{application}: parameters: - name: application in: path required: true schema: { type: integer } get: tags: [Applications] summary: Show an application responses: "200": description: The application content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Application" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Applications] summary: Handle an application description: | Only how it is being handled changes: `status` and the private `notes`. Moving it to `contacted`, `accepted` or `declined` records who did it and when; back to `new` clears both. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ApplicationWrite" } responses: "200": description: Application updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Application" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Applications] summary: Soft-delete an application responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/applications/{application}/restore: post: tags: [Applications] summary: Restore a soft-deleted application parameters: - name: application in: path required: true schema: { type: integer } responses: "200": description: Application restored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Application" } "403": { $ref: "#/components/responses/Forbidden" } /v1/redirects: get: tags: [Redirects] summary: List redirects parameters: - $ref: "#/components/parameters/PerPage" - name: is_enabled in: query schema: { type: boolean } - name: locale in: query schema: { type: string } - name: automatic in: query description: "True for the ones a slug rename left behind, false for those typed by hand." schema: { type: boolean } - name: search in: query description: "Matches either end." schema: { type: string } responses: "200": description: Paginated redirects content: application/json: schema: { $ref: "#/components/schemas/RedirectCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Redirects] summary: Create a redirect description: | Both ends are normalised — leading slash, no trailing one, no query string. A `locale` of null applies in every language. Creating one marks the matching missing URLs as resolved. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RedirectWrite" } responses: "201": description: Redirect created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Redirect" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/redirects/{redirect}: parameters: - name: redirect in: path required: true schema: { type: integer } get: tags: [Redirects] summary: Show a redirect responses: "200": description: The redirect content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Redirect" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Redirects] summary: Update a redirect description: Partial update. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RedirectWrite" } responses: "200": description: Redirect updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Redirect" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Redirects] summary: Delete a redirect responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/missing-urls: get: tags: [Redirects] summary: List missing urls parameters: - $ref: "#/components/parameters/PerPage" - name: status in: query description: "`pending` (the default), `resolved` or `ignored`." schema: { type: string } - name: locale in: query schema: { type: string } - name: search in: query description: "Matches the path." schema: { type: string } responses: "200": description: Paginated missing URLs content: application/json: schema: { $ref: "#/components/schemas/MissingUrlCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/missing-urls/{missing_url}: parameters: - name: missing_url in: path required: true schema: { type: integer } get: tags: [Redirects] summary: Show a missing URL responses: "200": description: The missing URL content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/MissingUrl" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Redirects] summary: Ignore a missing URL, or stop ignoring it description: "`status` moves between `pending` and `ignored` only: `resolved` is what giving it a redirect does." requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/MissingUrlWrite" } responses: "200": description: MissingUrl updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/MissingUrl" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Redirects] summary: Delete a missing URL responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/missing-urls/{missing_url}/redirect: post: tags: [Redirects] summary: Give a missing URL a destination description: | Creates the redirect from its path, which marks it resolved. `locale` defaults to the language it was seen in; send null for every language. parameters: - name: missing_url in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [to] properties: to: { type: string, maxLength: 500 } status: { type: integer, enum: [301, 302], default: 301 } locale: { type: string, nullable: true } responses: "201": description: Redirect created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Redirect" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/repositories: get: tags: [Repositories] summary: List repositories parameters: - $ref: "#/components/parameters/PerPage" - name: provider in: query description: "`gitea`, `github` or `gitlab`." schema: { type: string } - name: project_id in: query schema: { type: integer } - name: show_publicly in: query schema: { type: boolean } - name: failing in: query description: "Only those whose sync has failed three times or more." schema: { type: boolean } - name: search in: query description: "Matches the full name." schema: { type: string } responses: "200": description: Paginated repositories content: application/json: schema: { $ref: "#/components/schemas/RepositoryCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Repositories] summary: Create a repository description: | Only which repository, whose project and whether to show it. Everything else is what the provider reports on the next sync. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RepositoryWrite" } responses: "201": description: Repository created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Repository" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/repositories/{repository}: parameters: - name: repository in: path required: true schema: { type: integer } get: tags: [Repositories] summary: Show a repository responses: "200": description: The repository content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Repository" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Repositories] summary: Update a repository description: Partial update. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/RepositoryWrite" } responses: "200": description: Repository updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Repository" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Repositories] summary: Delete a repository responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/repositories/{repository}/sync: post: tags: [Repositories] summary: Sync a repository description: | Queues a sync with the provider and answers straight away. Requires `repositories.sync`: a sync spends a rate limit the whole application shares. parameters: - name: repository in: path required: true schema: { type: integer } responses: "202": description: Sync queued content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Repository" } "403": { $ref: "#/components/responses/Forbidden" } /v1/settings: get: tags: [Settings] summary: List settings description: Every stored setting, unpaginated. Requires `settings.view`. responses: "200": description: The settings content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Setting" } "403": { $ref: "#/components/responses/Forbidden" } /v1/settings/{setting}: parameters: - name: setting in: path required: true description: "The setting's key, e.g. `site.maintenance`." schema: { type: string } get: tags: [Settings] summary: Show a setting responses: "200": description: The setting content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Setting" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Settings] summary: Update a setting description: | Replaces the value whole. Requires `settings.update`, or `settings.maintenance` for `site.maintenance`. `content.version` is written by the application and refused here. requestBody: required: true content: application/json: schema: type: object required: [value] properties: value: { description: "Any JSON value, null included." } responses: "200": description: Setting updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Setting" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/maintenance: get: tags: [Settings] summary: Show maintenance mode description: The public site's maintenance mode. Requires `settings.view`. responses: "200": description: Maintenance state content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Maintenance" } "403": { $ref: "#/components/responses/Forbidden" } put: tags: [Settings] summary: Switch maintenance mode description: | Only the public site goes down; the panel and this API keep working. `message` (language → text) and `until` are kept either way, so the wording can be written before switching it on. A passed `until` lifts it by itself. Requires `settings.maintenance`. requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: { type: boolean } message: { type: object, nullable: true, additionalProperties: { type: string, maxLength: 500 } } until: { type: string, format: date-time, nullable: true } responses: "200": description: Maintenance state content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Maintenance" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}/revisions: get: tags: [History] summary: List the history of a page description: Who changed what and when, newest first. Binned records included. parameters: - name: page in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/categories/{category}/revisions: get: tags: [History] summary: List the history of a category description: Who changed what and when, newest first. Binned records included. parameters: - name: category in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/projects/{project}/revisions: get: tags: [History] summary: List the history of a project description: Who changed what and when, newest first. Binned records included. parameters: - name: project in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/members/{member}/revisions: get: tags: [History] summary: List the history of a member description: Who changed what and when, newest first. Binned records included. parameters: - name: member in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/services/{service}/revisions: get: tags: [History] summary: List the history of a service description: Who changed what and when, newest first. Binned records included. parameters: - name: service in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/clients/{client}/revisions: get: tags: [History] summary: List the history of a client description: Who changed what and when, newest first. Binned records included. parameters: - name: client in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/testimonials/{testimonial}/revisions: get: tags: [History] summary: List the history of a testimonial description: Who changed what and when, newest first. Binned records included. parameters: - name: testimonial in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/talks/{talk}/revisions: get: tags: [History] summary: List the history of a talk description: Who changed what and when, newest first. Binned records included. parameters: - name: talk in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/technologies/{technology}/revisions: get: tags: [History] summary: List the history of a technology description: Who changed what and when, newest first. Binned records included. parameters: - name: technology in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/calls/{call}/revisions: get: tags: [History] summary: List the history of a call description: Who changed what and when, newest first. Binned records included. parameters: - name: call in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: event in: query description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`." schema: { type: string } responses: "200": description: Paginated revisions content: application/json: schema: { $ref: "#/components/schemas/RevisionCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/revisions/{revision}/restore: post: tags: [History] summary: Undo a revision description: | Puts back the values one revision changed, as the panel's history does. Restoring is itself a change and lands in the history. Requires permission to edit the record, and to publish it when the revision touched its publication. parameters: - name: revision in: path required: true schema: { type: integer } responses: "200": description: The revision the restore recorded content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Revision" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/reviews: get: tags: [Reviews] summary: What is waiting for my review description: | Pages, projects and services awaiting review that the caller could publish — addressed to them, or to nobody in particular. Oldest request first, unpaginated. responses: "200": description: The queue content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/ReviewState" } /v1/pages/{page}/reviews: get: tags: [Reviews] summary: Review thread of a page description: Requests, returns, publications and comments, newest first. parameters: - name: page in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" responses: "200": description: Paginated thread content: application/json: schema: { $ref: "#/components/schemas/ReviewCommentCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/pages/{page}/comments: post: tags: [Reviews] summary: Comment on the review of a page parameters: - name: page in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, maxLength: 2000 } responses: "201": description: Comment added content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewComment" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}/review: post: tags: [Reviews] summary: Ask for a page to be reviewed description: | The way to put it live without `pages.publish`: whoever can publish it is told. `reviewer_id` addresses one of them; without it, anyone may answer. Refused if it is already waiting or already published. parameters: - name: page in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: reviewer_id: { type: integer, nullable: true } note: { type: string, nullable: true, maxLength: 2000 } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}/return: post: tags: [Reviews] summary: Send a page back for changes description: Tells whoever asked, with the reason. Requires `pages.publish`. parameters: - name: page in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [note] properties: note: { type: string, maxLength: 2000 } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}/publish: post: tags: [Reviews] summary: Publish a page, now or later description: | Approves it: live now, or from `at` when that is in the future. Any pending review is answered either way. Requires `pages.publish`. parameters: - name: page in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: at: { type: string, format: date-time, nullable: true } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/pages/{page}/unpublish: post: tags: [Reviews] summary: Take a page off the site description: Requires `pages.publish`. parameters: - name: page in: path required: true schema: { type: integer } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } /v1/projects/{project}/reviews: get: tags: [Reviews] summary: Review thread of a project description: Requests, returns, publications and comments, newest first. parameters: - name: project in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" responses: "200": description: Paginated thread content: application/json: schema: { $ref: "#/components/schemas/ReviewCommentCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/projects/{project}/comments: post: tags: [Reviews] summary: Comment on the review of a project parameters: - name: project in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, maxLength: 2000 } responses: "201": description: Comment added content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewComment" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/projects/{project}/review: post: tags: [Reviews] summary: Ask for a project to be reviewed description: | The way to put it live without `projects.publish`: whoever can publish it is told. `reviewer_id` addresses one of them; without it, anyone may answer. Refused if it is already waiting or already published. parameters: - name: project in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: reviewer_id: { type: integer, nullable: true } note: { type: string, nullable: true, maxLength: 2000 } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/projects/{project}/return: post: tags: [Reviews] summary: Send a project back for changes description: Tells whoever asked, with the reason. Requires `projects.publish`. parameters: - name: project in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [note] properties: note: { type: string, maxLength: 2000 } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/projects/{project}/publish: post: tags: [Reviews] summary: Publish a project, now or later description: | Approves it: live now, or from `at` when that is in the future. Any pending review is answered either way. Requires `projects.publish`. parameters: - name: project in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: at: { type: string, format: date-time, nullable: true } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/projects/{project}/unpublish: post: tags: [Reviews] summary: Take a project off the site description: Requires `projects.publish`. parameters: - name: project in: path required: true schema: { type: integer } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } /v1/services/{service}/reviews: get: tags: [Reviews] summary: Review thread of a service description: Requests, returns, publications and comments, newest first. parameters: - name: service in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" responses: "200": description: Paginated thread content: application/json: schema: { $ref: "#/components/schemas/ReviewCommentCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/services/{service}/comments: post: tags: [Reviews] summary: Comment on the review of a service parameters: - name: service in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, maxLength: 2000 } responses: "201": description: Comment added content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewComment" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/services/{service}/review: post: tags: [Reviews] summary: Ask for a service to be reviewed description: | The way to put it live without `services.publish`: whoever can publish it is told. `reviewer_id` addresses one of them; without it, anyone may answer. Refused if it is already waiting or already published. parameters: - name: service in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: reviewer_id: { type: integer, nullable: true } note: { type: string, nullable: true, maxLength: 2000 } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/services/{service}/return: post: tags: [Reviews] summary: Send a service back for changes description: Tells whoever asked, with the reason. Requires `services.publish`. parameters: - name: service in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object required: [note] properties: note: { type: string, maxLength: 2000 } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/services/{service}/publish: post: tags: [Reviews] summary: Publish a service, now or later description: | Approves it: live now, or from `at` when that is in the future. Any pending review is answered either way. Requires `services.publish`. parameters: - name: service in: path required: true schema: { type: integer } requestBody: required: true content: application/json: schema: type: object properties: at: { type: string, format: date-time, nullable: true } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/services/{service}/unpublish: post: tags: [Reviews] summary: Take a service off the site description: Requires `services.publish`. parameters: - name: service in: path required: true schema: { type: integer } responses: "200": description: Where the review stands content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/ReviewState" } "403": { $ref: "#/components/responses/Forbidden" } /v1/pages/{page}/preview: post: tags: [Reviews] summary: Preview links for a page description: | One link per language it has an address in, to see it on the real site before it is published. Each opens this record only and expires a few hours later. parameters: - name: page in: path required: true schema: { type: integer } responses: "201": description: Preview links content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PreviewLinks" } "403": { $ref: "#/components/responses/Forbidden" } /v1/projects/{project}/preview: post: tags: [Reviews] summary: Preview links for a project description: | One link per language it has an address in, to see it on the real site before it is published. Each opens this record only and expires a few hours later. parameters: - name: project in: path required: true schema: { type: integer } responses: "201": description: Preview links content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PreviewLinks" } "403": { $ref: "#/components/responses/Forbidden" } /v1/services/{service}/preview: post: tags: [Reviews] summary: Preview links for a service description: | One link per language it has an address in, to see it on the real site before it is published. Each opens this record only and expires a few hours later. parameters: - name: service in: path required: true schema: { type: integer } responses: "201": description: Preview links content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PreviewLinks" } "403": { $ref: "#/components/responses/Forbidden" } /v1/calls/{call}/preview: post: tags: [Reviews] summary: Preview links for a call description: | One link per language it has an address in, to see it on the real site before it is published. Each opens this record only and expires a few hours later. parameters: - name: call in: path required: true schema: { type: integer } responses: "201": description: Preview links content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/PreviewLinks" } "403": { $ref: "#/components/responses/Forbidden" } /v1/health: get: tags: [Health] summary: Site health description: | The panel's health screen: every check the caller may see, problems first — broken images, missing slugs, stale translations, unanswered submissions… A check behind a permission the caller lacks is left out. responses: "200": description: The findings content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/HealthFinding" } meta: type: object properties: problems: { type: integer } checked_at: { type: string, format: date-time } "403": { $ref: "#/components/responses/Forbidden" } /v1/translations/pending: get: tags: [Health] summary: What is left to translate description: | Pages, projects, services and calls with a language still to write (`missing`) or left behind by the one it was translated from (`stale`, with since when). Unpaginated. parameters: - name: locale in: query description: "Only what this language lacks." schema: { type: string } - name: type in: query description: "`pages`, `projects`, `services` or `calls`." schema: { type: string } - name: published in: query description: "Only what is on the site \u2014 open, for calls." schema: { type: boolean } responses: "200": description: The records content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PendingTranslation" } "422": { $ref: "#/components/responses/ValidationError" } /v1/batch: post: tags: [Batch] summary: Run several operations description: | Up to 50 admin API calls in one request. Each runs as a request of its own with the caller's credentials — validated, authorised and answered exactly as if sent separately — so a batch can do nothing its operations would be refused. Their answers come back in order. With `atomic`, it stops at the first failure and undoes everything before it. What already left the database — a queued job, a notification — is not undone. `path` is an admin path such as `/v1/pages/3`; batches do not nest and do not reach the import or the token exchange. requestBody: required: true content: application/json: schema: type: object required: [operations] properties: atomic: { type: boolean, default: false } operations: type: array maxItems: 50 items: type: object required: [method, path] properties: method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] } path: { type: string, example: /v1/technologies } body: { type: object, nullable: true, additionalProperties: true } responses: "200": description: Each operation's answer content: application/json: schema: { $ref: "#/components/schemas/BatchOutcome" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/import/{resource}: post: tags: [Batch] summary: Import records description: | Rows of one kind of record, as JSON (`rows`) or a CSV `file`, turned into the create and update calls the API already has — up to 50 per import, each validated and authorised like any other call. A row with an `id`, or whose `match` field (`slug`, `name` or `full_name`) names an existing record, updates it; any other row creates one. In a CSV, a column `title.es` fills the Spanish title, a list is written `1|2|3`, `true`/`false`/`null` are what they say, and an empty cell is left out rather than erasing what is there. All-or-nothing unless `atomic` is false. `dry_run` checks every row and keeps none. parameters: - name: resource in: path required: true schema: type: string enum: [pages, categories, projects, members, services, clients, testimonials, talks, technologies, calls] requestBody: required: true content: application/json: schema: type: object required: [rows] properties: rows: type: array maxItems: 50 items: { type: object, additionalProperties: true, description: The same body the resource's create endpoint takes. } match: { type: string, enum: [slug, name, full_name], nullable: true } atomic: { type: boolean, default: true } dry_run: { type: boolean, default: false } multipart/form-data: schema: type: object required: [file] properties: file: { type: string, format: binary, description: "CSV with a header row, UTF-8." } match: { type: string, enum: [slug, name, full_name] } atomic: { type: boolean, default: true } dry_run: { type: boolean, default: false } responses: "200": description: Each row's answer content: application/json: schema: { $ref: "#/components/schemas/BatchOutcome" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/webhooks: get: tags: [Webhooks] summary: List webhooks parameters: - $ref: "#/components/parameters/PerPage" responses: "200": description: Paginated webhooks content: application/json: schema: { $ref: "#/components/schemas/WebhookCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Webhooks] summary: Create a webhook description: | Somewhere to POST to when something happens here. HTTPS to a public address only — checked now and again at every delivery — and redirects are not followed. The `secret` signing the deliveries is generated unless sent, and is in this answer's `secret` once, never again. Each delivery is a JSON body `{id, event, created_at, data}` with headers `X-Webhook-Event`, `X-Webhook-Delivery`, `X-Webhook-Timestamp` and `X-Webhook-Signature`: `sha256=` and the HMAC-SHA256 of `{timestamp}.{body}` with the secret. Failed deliveries are retried five times over about two and a half hours; twenty failures in a row switch the webhook off. Events: `application.received` (its sender's details only with `include_personal_data`), `content.published`, `content.unpublished`, `review.requested`, `review.returned` — or `*`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/WebhookWrite" } responses: "201": description: Webhook created content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Webhook" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/webhooks/{webhook}: parameters: - name: webhook in: path required: true schema: { type: integer } get: tags: [Webhooks] summary: Show a webhook responses: "200": description: The webhook content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Webhook" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } patch: tags: [Webhooks] summary: Update a webhook description: "Partial update. `rotate_secret` makes a new secret, in this answer's `secret` once. Switching it back on resets its failure count." requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/WebhookWrite" } responses: "200": description: Webhook updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Webhook" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [Webhooks] summary: Delete a webhook responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } /v1/webhooks/{webhook}/deliveries: get: tags: [Webhooks] summary: Deliveries of a webhook description: Newest first. parameters: - name: webhook in: path required: true schema: { type: integer } - $ref: "#/components/parameters/PerPage" - name: status in: query description: "`pending`, `succeeded` or `failed`." schema: { type: string } responses: "200": description: Paginated deliveries content: application/json: schema: { $ref: "#/components/schemas/WebhookDeliveryCollection" } "403": { $ref: "#/components/responses/Forbidden" } /v1/webhooks/{webhook}/ping: post: tags: [Webhooks] summary: Send a test delivery description: A `ping` event, to check the receiving end. parameters: - name: webhook in: path required: true schema: { type: integer } responses: "202": description: Ping queued content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/WebhookDelivery" } "403": { $ref: "#/components/responses/Forbidden" } /v1/webhook-deliveries/{delivery}/redeliver: post: tags: [Webhooks] summary: Send a delivery again description: As a new delivery of the same event and payload. parameters: - name: delivery in: path required: true schema: { type: integer } responses: "202": description: Delivery queued content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/WebhookDelivery" } "403": { $ref: "#/components/responses/Forbidden" } /v1/media: get: tags: [Media] summary: List the media library description: Every image on the media disk, newest first. Requires `media.view_any`. parameters: - $ref: "#/components/parameters/PerPage" - name: for in: query description: The kind of record it was uploaded for. schema: { $ref: "#/components/schemas/MediaFor" } - name: mime in: query description: "e.g. `image/svg+xml`." schema: { type: string } - name: search in: query description: "Matches the path." schema: { type: string } - name: pending in: query description: "Only those the queue has not processed yet." schema: { type: boolean } - name: failed in: query description: "Only those whose processing failed." schema: { type: boolean } - name: orphans in: query description: "Only those nothing points at \u2014 what `content:prune --media` would delete." schema: { type: boolean } responses: "200": description: Paginated media content: application/json: schema: { $ref: "#/components/schemas/MediaCollection" } "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Media] summary: Upload an image description: | Stores an image and answers with the `path` a record points at — send it as a page's `featured_image`. Two ways in, exactly one per request: - **`multipart/form-data`** with the image as `file`. - **`application/json`** with a `url`, which the API downloads itself. HTTPS only, from a public address — anything resolving to a private, reserved or loopback address is refused — and redirects are not followed: send the image's final address. JPEG, PNG, WebP, GIF, AVIF or SVG, decided from the file's bytes rather than its name or declared type. An SVG is stored sanitized: scripts, event handlers and references to anything outside the file are removed. 20 MB at most. `for` says which kind of record the image is for — `pages` (the default), `technologies`, `projects`, `members`, `clients` or `testimonials` — which decides where it is stored and whose permission it takes. The smaller versions and the placeholder are derived afterwards, on the queue: `processed` says whether that has happened, and the image serves either way. Requires permission to create or edit the records named in `for`. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary description: The image. for: { $ref: "#/components/schemas/MediaFor" } application/json: schema: type: object required: [url] properties: url: type: string format: uri description: An `https://` address the API downloads the image from. example: https://images.example.org/hero.jpg for: { $ref: "#/components/schemas/MediaFor" } responses: "201": description: Image stored content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Media" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/media/{media}: parameters: - name: media in: path required: true schema: { type: integer } get: tags: [Media] summary: Show an image responses: "200": description: The image, with whether anything uses it content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Media" } "403": { $ref: "#/components/responses/Forbidden" } "404": { description: Not found } delete: tags: [Media] summary: Delete an image description: | The file, its derived versions and its record. Only when nothing points at it — a page, a logo, an image inside a text — since deleting one in use would put a broken image on the public site. Requires `media.delete`. responses: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } "409": { description: The image is in use. } /v1/media/{media}/reprocess: post: tags: [Media] summary: Process an image again description: Queues deriving the smaller versions and the placeholder again. parameters: - name: media in: path required: true schema: { type: integer } responses: "202": description: Processing queued content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Media" } "403": { $ref: "#/components/responses/Forbidden" } /v1/users: get: tags: [Users] summary: List local users description: Requires `users.view_any`. parameters: - $ref: "#/components/parameters/PerPage" - name: search in: query schema: { type: string } - name: is_active in: query schema: { type: boolean } - name: role in: query schema: { type: string } responses: "200": description: Paginated users content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/User" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } "403": { $ref: "#/components/responses/Forbidden" } /v1/users/{user}: parameters: - name: user in: path required: true schema: { type: integer } get: tags: [Users] summary: Show a user description: Reading your own record is always permitted. responses: "200": description: The user content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/User" } patch: tags: [Users] summary: Update a user's local access description: | Only fields this application owns are writable. Name and email belong to the identity server and are refreshed on every sign-in, so change those at `oauth.pireon.dev` instead. Assigning roles additionally requires `roles.update`, and only a super admin may grant `super_admin`. requestBody: required: true content: application/json: schema: type: object properties: is_active: { type: boolean } locale: { type: string, enum: [es, en, ca], nullable: true } roles: type: array items: { type: string } example: [editor] responses: "200": description: User updated content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/User" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } /v1/roles: get: tags: [Roles] summary: List roles and their permissions responses: "200": description: All roles content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/Role" } "403": { $ref: "#/components/responses/Forbidden" } /v1/roles/catalogue: get: tags: [Roles] summary: The full permission catalogue, grouped by resource description: Useful for building a role editor outside the panel. responses: "200": description: Permission catalogue content: application/json: schema: type: object properties: data: type: object additionalProperties: type: array items: { type: string } example: general: [panel.access] pages: [pages.view_any, pages.create, pages.publish] /v1/roles/{role}: get: tags: [Roles] summary: Show a role parameters: - name: role in: path required: true schema: { type: integer } responses: "200": description: The role content: application/json: schema: type: object properties: data: { $ref: "#/components/schemas/Role" } components: securitySchemes: bearerAuth: type: http scheme: bearer description: | An access token issued by https://oauth.pireon.dev, validated on each request against the identity server's `GET /api/user` with the result cached briefly — or a machine token (`pmt_…`) from `POST /v1/tokens`. ingestToken: type: apiKey in: header name: X-Ingest-Token description: | Shared secret held by the frontend, for the endpoints it calls on its own behalf rather than on a visitor's. In a header rather than the query string so it stays out of access logs. An unset secret disables those endpoints instead of opening them. parameters: Lang: name: lang in: query description: Language to resolve translatable fields into, overriding Accept-Language. schema: { type: string, enum: [es, en, ca] } With: name: with in: query description: Comma-separated extras. `translations` adds every language. schema: { type: string, example: translations } PreviewToken: name: X-Preview-Token in: header description: | Shared secret that lets the frontend render unpublished content. When valid, the `published` filter is skipped and the response is marked `no-store`. An unset secret never grants access. schema: { type: string } IngestToken: name: X-Ingest-Token in: header required: true description: Shared secret for the endpoints the frontend calls on its own behalf. schema: { type: string } PerPage: name: per_page in: query schema: { type: integer, default: 15, maximum: 100 } responses: Maintenance: description: | The public site is in maintenance. Returned by every `/public/v1` endpoint while the flag is on, with the notice already resolved to the request's language so the frontend can render a maintenance page from it directly. Never cached, and accompanied by `Retry-After`. The panel and the admin API are unaffected — this is deliberately not Laravel's own maintenance mode. headers: Retry-After: schema: { type: integer, example: 3600 } content: application/json: schema: type: object properties: message: { type: string, example: Estamos migrando el Gitea. Volvemos en un rato. } maintenance: { type: boolean, example: true } locale: { type: string, example: es } since: { type: string, format: date-time, nullable: true } until: { type: string, format: date-time, nullable: true } Unauthorized: description: Missing, expired, or revoked bearer token content: application/json: schema: type: object properties: message: { type: string, example: Unauthenticated. } Forbidden: description: The caller lacks the required local permission content: application/json: schema: type: object properties: message: { type: string, example: This action is unauthorized. } ValidationError: description: Validation failed content: application/json: schema: type: object properties: message: { type: string } errors: type: object additionalProperties: type: array items: { type: string } example: slug: ['El slug «inicio» ya está en uso en el idioma «es».'] SubmissionAccepted: description: | Stored, and the people who can read the inbox have been told. The body of the submission is never echoed back, so a guessed id cannot be used to read somebody else's message. Always `no-store`. headers: Cache-Control: schema: { type: string, example: "no-store, private" } content: application/json: schema: { $ref: "#/components/schemas/SubmissionReceipt" } schemas: Image: type: object description: | Everything needed to render an image well server-side. A path alone was never enough: without `width`/`height` the page reflows as images arrive, without `srcset` every visitor downloads the original, and without `alt` the page is not accessible. Degrades on purpose — an upload whose derived versions are not ready yet still carries a `url`, with the rest null, so the frontend renders a plain `` rather than a gap. properties: url: type: string format: uri description: Absolute, because the frontend is on another host. alt: type: string description: | Empty string when the image is decorative — that is what `alt=""` means — never absent. Follows the requested language. width: { type: integer, nullable: true } height: { type: integer, nullable: true } aspect_ratio: { type: number, nullable: true } mime: { type: string, nullable: true } placeholder: type: string nullable: true description: | Tiny blurred data URI to paint while the real image loads. srcset: type: object nullable: true additionalProperties: { type: string } description: | Keyed by format, ready to use verbatim — build a `` with AVIF first and WebP behind it. Only widths that actually exist are listed, and an image is never scaled up. example: webp: "https://admin.example/storage/projects/cover-400.webp 400w, https://admin.example/storage/projects/cover-800.webp 800w" ViewReport: type: object required: [type, slug] properties: type: type: string enum: [page, project, member, call, service] description: | The same vocabulary `/slugs` and the path configuration use, so a report can only ever name something this API serves. slug: type: string maxLength: 200 example: mi-web description: The slug in the language given below, not a full path. locale: type: string enum: [es, en, ca] description: | Which language was rendered. Falls back to the negotiated locale. Counted separately per language, because which language people read something in is most of what these figures are for. count: type: integer minimum: 1 maximum: 1000 default: 1 description: | For a frontend that batches rather than reporting each render. Capped: the frontend is trusted to count its own renders, not to invent an audience. MissingUrlReport: type: object required: [path] properties: path: type: string maxLength: 500 pattern: "^/" example: /proyectos/nombre-viejo description: | A path, not a URL. The frontend knows its own origin, and accepting a full address would let a caller file a 404 against somebody else's domain. Normalised on arrival: query strings and trailing slashes are stripped, so `/algo/` and `/algo?utm_source=x` are one address rather than three. locale: type: string enum: [es, en, ca] description: | Which language was being rendered. Falls back to the negotiated locale when absent, because it is half of the key that keeps one address in two languages as two separate problems — they need two different redirects. referrer: type: string format: uri maxLength: 500 nullable: true description: | Straight from the `Referer` header. The most useful field here: one on our own domain means something we publish links to a dead address, which the panel flags as an error rather than a warning. Submission: type: object required: [name, email, message, consent] properties: name: { type: string, minLength: 2, maxLength: 120 } email: { type: string, format: email, maxLength: 255 } message: type: string minLength: 20 maxLength: 5000 description: | The minimum length costs a real person nothing and rules out the one-word submissions that are never anything but noise. organisation: type: string maxLength: 160 nullable: true description: Required when `kind` is `request`. hours_per_week: type: integer minimum: 1 maximum: 40 nullable: true description: | Mirrors the call's own ask, so the two numbers can be compared without reading the message. links: type: object maxProperties: 5 additionalProperties: { type: string, format: uri } example: { github: "https://github.com/ada", web: "https://ada.example" } consent: type: boolean description: | Must be `true`. The sender is not a user of this system, so their agreement to have their details stored travels with the payload rather than being assumed of the frontend. website: type: string nullable: true description: | Honeypot — the field name comes from `SITE_HONEYPOT_FIELD`. Never render it visibly. A submission carrying it is filed as spam rather than dropped, and still answers 201, so a bot learns nothing and a mistakenly-tripped human is recoverable. SubmissionReceipt: type: object properties: data: type: object properties: id: { type: integer } kind: { type: string, enum: [contribute, request] } status: { type: string, example: new } duplicate: type: boolean description: True when this repeated an earlier submission. message: type: string description: Confirmation text, already in the visitor's language. example: Hemos recibido tu mensaje. Te contestamos en cuanto podamos. PublicProject: type: object properties: id: { type: integer } locale: { type: string, example: es } title: { type: string } slug: { type: string } tagline: { type: string, nullable: true } description: type: string nullable: true description: Detail endpoint only. decisions: type: string nullable: true description: | Why the technical choices were made. Detail endpoint only. postmortem: type: string nullable: true description: | What went wrong and what was learned. Detail endpoint only. meta_title: type: string nullable: true description: | What a search engine or a chat preview should show instead of the title. Detail endpoint only. meta_description: type: string nullable: true description: Detail endpoint only. status: { type: string, enum: [idea, active, paused, completed, archived] } is_featured: { type: boolean } needs_help: type: boolean description: Flagged directly, or carrying at least one open call. cover_image: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true gallery: type: array description: | Screenshots and before-and-after, in the order they were arranged in the panel. **Detail endpoint only** — a listing of twenty projects has no use for every screenshot of each one. items: allOf: - $ref: "#/components/schemas/Image" - type: object properties: caption: type: string nullable: true description: | Shown to everyone, unlike `alt`, which is read by whoever cannot see the image. "Before" and "after" are captions. demo_url: { type: string, nullable: true } client: allOf: [{ $ref: "#/components/schemas/PublicClient" }] nullable: true description: | The NGO or association the work serves. Used to be two free-text fields, which made the same organisation two different things. services: type: array items: { $ref: "#/components/schemas/PublicService" } testimonials: type: array items: { $ref: "#/components/schemas/PublicTestimonial" } talks: type: array items: { $ref: "#/components/schemas/PublicTalk" } started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: | When the record leaves the site. Enforced by the query itself, so it holds whether or not anything is running. Use it to expire a cache downstream at the same moment. updated_at: { type: string, format: date-time } technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } members: type: array items: { $ref: "#/components/schemas/PublicMember" } lead: { $ref: "#/components/schemas/PublicMember" } calls: type: array items: { $ref: "#/components/schemas/PublicCall" } repositories: type: array items: { $ref: "#/components/schemas/PublicRepository" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true, example: /proyectos/mi-web } PublicMember: type: object properties: id: { type: integer } name: { type: string } slug: { type: string } headline: { type: string, nullable: true } bio: { type: string, nullable: true } avatar: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true github_username: { type: string, nullable: true } links: type: object additionalProperties: { type: string } example: { github: "https://github.com/ada", website: "https://ada.dev" } is_founder: { type: boolean } is_current: type: boolean description: False for people who have left; their work is kept. joined_at: { type: string, format: date, nullable: true } left_at: { type: string, format: date, nullable: true } role: type: string nullable: true description: Their role on a project, when nested under one. technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } projects: type: array items: { $ref: "#/components/schemas/PublicProject" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, example: /equipo/ada } PublicCall: type: object description: | A call for contributors. The commitment fields are the point: time is the scarce resource in a volunteer collective, and stating it plainly is what lets someone decide. properties: id: { type: integer } locale: { type: string } title: { type: string } slug: { type: string } description: { type: string, nullable: true } what_you_get: type: string nullable: true description: The explicit exchange offered for unpaid work. status: { type: string, enum: [open, filled, closed] } hours_per_week: { type: integer, nullable: true, example: 4 } duration_weeks: { type: integer, nullable: true, example: 8 } total_hours: { type: integer, nullable: true, example: 32 } seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } closes_at: { type: string, format: date, nullable: true } project: { $ref: "#/components/schemas/PublicProject" } technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } PublicSearchResult: type: object properties: type: { type: string, enum: [page, project, service, call, member, technology] } id: { type: integer } title: { type: string } excerpt: { type: string, description: Plain text around the first match. } path: { type: string, nullable: true, example: /proyectos/mi-proyecto } score: { type: number, description: Higher is a better match. Only comparable within one response. } PublicTechnology: type: object properties: id: { type: integer } name: type: string description: Not translated — technology names are proper nouns. example: Laravel slug: { type: string } category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } color: { type: string, nullable: true } icon: { type: string, nullable: true } logo: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true description: | The technology's own logo. Its `alt` is the technology's name — there is nothing to write and nothing to get wrong. website: { type: string, nullable: true } ring: type: string nullable: true enum: [adopt, trial, assess, hold] description: Radar placement. Null for technologies used but not placed. description: { type: string, nullable: true } radar_note: type: string nullable: true description: Why it sits in that ring. projects_count: { type: integer } members_count: { type: integer } PublicRepository: type: object description: Mirrored from the git provider and refreshed hourly. properties: id: { type: integer } provider: { type: string, enum: [github, gitlab, gitea] } full_name: { type: string, example: colectivo/web } url: { type: string, nullable: true } description: { type: string, nullable: true } primary_language: { type: string, nullable: true } languages: type: object additionalProperties: { type: integer } description: Bytes of code per language. topics: type: array items: { type: string } license: { type: string, nullable: true } stars: { type: integer } forks: { type: integer } contributors_count: { type: integer } open_issues_count: { type: integer } is_archived: { type: boolean } is_dormant: type: boolean description: | Nothing pushed for six months. Lets the frontend avoid presenting dead work as live. pushed_at: { type: string, format: date-time, nullable: true } synced_at: { type: string, format: date-time, nullable: true } good_first_issues: type: array items: { $ref: "#/components/schemas/PublicIssue" } PublicIssue: type: object properties: number: { type: integer } title: { type: string } url: { type: string } labels: type: array items: { type: string } is_good_first_issue: { type: boolean } is_help_wanted: { type: boolean } is_available: type: boolean description: Nobody has claimed it. A claimed issue is not an invitation. comments: { type: integer } created_at: { type: string, format: date-time, nullable: true } repository: type: object properties: full_name: { type: string } url: { type: string, nullable: true } primary_language: { type: string, nullable: true } project: type: object nullable: true properties: title: { type: string } slug: { type: string } PublicService: type: object properties: id: { type: integer } locale: { type: string } title: { type: string } slug: { type: string } tagline: { type: string, nullable: true } description: type: string nullable: true description: Detail endpoint only. meta_title: type: string nullable: true description: | What a search engine or a chat preview should show instead of the title. Detail endpoint only. meta_description: type: string nullable: true description: Detail endpoint only. icon: type: string nullable: true description: A Heroicons name, e.g. `heroicon-o-code-bracket`. is_featured: { type: boolean } position: { type: integer } updated_at: type: string format: date-time nullable: true description: | So a cache downstream can tell whether its copy is still the current one. projects: type: array description: The published work that backs the offer. Detail endpoint only. items: { $ref: "#/components/schemas/PublicProject" } technologies: type: array items: { $ref: "#/components/schemas/PublicTechnology" } testimonials: type: array items: { $ref: "#/components/schemas/PublicTestimonial" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } PublicClient: type: object properties: id: { type: integer } name: type: string description: A proper noun, so it is the same in every language. slug: { type: string } sector: { type: string, nullable: true } url: { type: string, nullable: true } logo: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true description: Its alt text is the organisation's name. projects_count: { type: integer } PublicTestimonial: type: object properties: id: { type: integer } quote: { type: string } original_locale: type: string description: The language it was actually said in. is_original: type: boolean description: | False when the quote you are reading was translated by us. Say so — "translated from Spanish" — rather than implying they phrased it that way. author: type: object properties: name: { type: string } role: { type: string, nullable: true } avatar: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true given_at: { type: string, format: date, nullable: true } client: allOf: [{ $ref: "#/components/schemas/PublicClient" }] nullable: true PublicTalk: type: object properties: id: { type: integer } title: type: string description: | Not translated: the name of a talk given once, in one language. language: { type: string } summary: { type: string, nullable: true } event: { type: string } city: { type: string, nullable: true } held_at: { type: string, format: date, nullable: true } video_url: { type: string, nullable: true } slides_url: { type: string, nullable: true } speaker: allOf: [{ $ref: "#/components/schemas/PublicMember" }] nullable: true PublicPage: type: object properties: id: { type: integer } locale: { type: string } title: { type: string, nullable: true } slug: { type: string, nullable: true } excerpt: { type: string, nullable: true } body: { type: string, nullable: true } meta_title: { type: string, nullable: true } meta_description: { type: string, nullable: true } status: { type: string } is_featured: { type: boolean } featured_image: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: | When the record leaves the site. Enforced by the query itself, so it holds whether or not anything is running. Use it to expire a cache downstream at the same moment. updated_at: { type: string, format: date-time } category: { $ref: "#/components/schemas/PublicCategory" } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } PublicCategory: type: object properties: id: { type: integer } name: { type: string, nullable: true } slug: { type: string, nullable: true } description: { type: string, nullable: true } color: { type: string, nullable: true } position: { type: integer } pages_count: { type: integer } Alternates: type: object description: | The same record's path in each language that has a translation. Feed these straight into ``. Languages with no translation are absent, so no advertised URL ever 404s. additionalProperties: { type: string } example: es: /proyectos/mi-web en: /en/projects/my-web ca: /ca/projectes/el-meu-web TranslatedField: description: | Either a plain string (applies to the active language) or an object keyed by locale. oneOf: - type: string - type: object additionalProperties: { type: string, nullable: true } example: { es: "Inicio", en: "Home", ca: "Inici" } Page: type: object properties: id: { type: integer } locale: type: string description: The language the fields below were resolved into. example: es title: { type: string, nullable: true } slug: { type: string, nullable: true } excerpt: { type: string, nullable: true } body: { type: string, nullable: true } meta_title: { type: string, nullable: true } meta_description: { type: string, nullable: true } status: { type: string, enum: [draft, review, published, archived] } status_label: { type: string, description: Translated status name. } is_featured: { type: boolean } featured_image: type: string nullable: true description: | The stored path, because this is the editing API and it is what a client PATCHes back. What was derived from it travels alongside. featured_image_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: | When the record leaves the site. Enforced by the query itself, so it holds whether or not anything is running. Use it to expire a cache downstream at the same moment. created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } category: { $ref: "#/components/schemas/Category" } author: { $ref: "#/components/schemas/User" } featured_image_alt: type: string nullable: true description: | What the image shows, resolved into the language above — unlike `PageWrite`'s field of the same name, which carries every language at once. translations: type: object nullable: true description: Present only when `?with=translations` was passed. additionalProperties: type: object additionalProperties: { type: string } PageWrite: type: object required: [title, slug] properties: title: { $ref: "#/components/schemas/TranslatedField" } slug: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: Unique per language. The same slug may be reused across languages. excerpt: { $ref: "#/components/schemas/TranslatedField" } body: { $ref: "#/components/schemas/TranslatedField" } meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } category_id: { type: integer, nullable: true } status: { type: string, enum: [draft, review, published, archived] } featured_image: type: string nullable: true description: Path returned by the upload, e.g. "pages/hero.jpg". featured_image_alt: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: | What the image shows, per language. Belongs to the record rather than to the file: the same image can need a different description depending on where it is used. is_featured: { type: boolean } published_at: { type: string, format: date-time, nullable: true } unpublish_at: type: string format: date-time nullable: true description: Must be after `published_at`. When it arrives the record leaves the site on its own. Project: type: object properties: id: { type: integer } locale: { type: string, description: The language the fields below were resolved into. } title: { type: string, nullable: true } slug: { type: string, nullable: true } tagline: { type: string, nullable: true } description: { type: string, nullable: true, description: HTML from the panel's rich editor. } decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. } postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. } meta_title: { type: string, nullable: true } meta_description: { type: string, nullable: true } cover_image_alt: { type: string, nullable: true } status: { type: string, enum: [idea, active, paused, completed, archived] } is_featured: { type: boolean } needs_help: { type: boolean } demo_url: { type: string, nullable: true, format: uri } cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. } cover_image_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. } unpublish_at: { type: string, format: date-time, nullable: true } position: { type: integer } client_id: { type: integer, nullable: true } lead_member_id: { type: integer, nullable: true } technology_ids: { type: array, items: { type: integer } } service_ids: { type: array, items: { type: integer } } members: type: array items: type: object properties: id: { type: integer } name: { type: string } role: { type: string, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } ProjectWrite: type: object required: [title, slug] properties: title: { $ref: "#/components/schemas/TranslatedField" } slug: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: Unique per language. tagline: { $ref: "#/components/schemas/TranslatedField" } description: { $ref: "#/components/schemas/TranslatedField" } decisions: { $ref: "#/components/schemas/TranslatedField" } postmortem: { $ref: "#/components/schemas/TranslatedField" } meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } cover_image_alt: { $ref: "#/components/schemas/TranslatedField" } status: { type: string, enum: [idea, active, paused, completed, archived] } is_featured: { type: boolean } needs_help: { type: boolean } demo_url: { type: string, format: uri, nullable: true } cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. } started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. } unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. } position: { type: integer, minimum: 0 } client_id: { type: integer, nullable: true } lead_member_id: { type: integer, nullable: true } technology_ids: { type: array, items: { type: integer } } service_ids: { type: array, items: { type: integer } } members: type: array description: The whole team, replacing the previous one. items: type: object required: [id] properties: id: { type: integer } role: { type: string, nullable: true, maxLength: 120 } ProjectCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Project" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Member: type: object properties: id: { type: integer } locale: { type: string } name: { type: string } slug: { type: string } headline: { type: string, nullable: true } bio: { type: string, nullable: true, description: Plain text. } avatar: { type: string, nullable: true, description: The stored path. } avatar_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true email: { type: string, nullable: true, description: Never shown on the site. } github_username: { type: string, nullable: true } links: { type: object, additionalProperties: { type: string }, description: Label → URL. } is_public: { type: boolean } is_founder: { type: boolean } joined_at: { type: string, format: date, nullable: true } left_at: { type: string, format: date, nullable: true } position: { type: integer } technology_ids: { type: array, items: { type: integer } } projects: type: array items: type: object properties: id: { type: integer } title: { type: string, nullable: true } role: { type: string, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } MemberWrite: type: object required: [name, slug] properties: name: { type: string, maxLength: 255 } slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" } headline: { $ref: "#/components/schemas/TranslatedField" } bio: { $ref: "#/components/schemas/TranslatedField" } avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. } email: { type: string, format: email, nullable: true } github_username: { type: string, nullable: true, maxLength: 120 } links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } } is_public: { type: boolean } is_founder: { type: boolean } joined_at: { type: string, format: date, nullable: true } left_at: { type: string, format: date, nullable: true } position: { type: integer, minimum: 0 } technology_ids: { type: array, items: { type: integer } } MemberCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Member" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Service: type: object properties: id: { type: integer } locale: { type: string } title: { type: string, nullable: true } slug: { type: string, nullable: true } tagline: { type: string, nullable: true } description: { type: string, nullable: true, description: Plain text. } meta_title: { type: string, nullable: true } meta_description: { type: string, nullable: true } icon: { type: string, nullable: true } is_featured: { type: boolean } is_published: { type: boolean } published_at: { type: string, format: date-time, nullable: true } unpublish_at: { type: string, format: date-time, nullable: true } position: { type: integer } technology_ids: { type: array, items: { type: integer } } project_ids: { type: array, items: { type: integer } } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } ServiceWrite: type: object required: [title, slug] properties: title: { $ref: "#/components/schemas/TranslatedField" } slug: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: Unique per language. tagline: { $ref: "#/components/schemas/TranslatedField" } description: { $ref: "#/components/schemas/TranslatedField" } meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." } is_featured: { type: boolean } position: { type: integer, minimum: 0 } is_published: { type: boolean, description: Requires `services.publish`. } published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. } unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. } technology_ids: { type: array, items: { type: integer } } project_ids: { type: array, items: { type: integer } } ServiceCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Service" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Client: type: object properties: id: { type: integer } locale: { type: string } name: { type: string } slug: { type: string } sector: { type: string, nullable: true } logo: { type: string, nullable: true, description: The stored path. } logo_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true url: { type: string, format: uri, nullable: true } is_public: { type: boolean } position: { type: integer } projects_count: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } ClientWrite: type: object required: [name, slug] properties: name: { type: string, maxLength: 255, description: A proper noun; not translated. } slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" } sector: { $ref: "#/components/schemas/TranslatedField" } logo: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=clients`. } url: { type: string, format: uri, nullable: true } is_public: { type: boolean } position: { type: integer, minimum: 0 } ClientCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Client" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Testimonial: type: object properties: id: { type: integer } locale: { type: string } quote: { type: string, nullable: true } author_role: { type: string, nullable: true } original_locale: { type: string, description: The language the person spoke in. } author_name: { type: string } avatar: { type: string, nullable: true, description: The stored path. } avatar_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true client_id: { type: integer, nullable: true } project_id: { type: integer, nullable: true } service_id: { type: integer, nullable: true } is_public: { type: boolean } position: { type: integer } given_at: { type: string, format: date, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } TestimonialWrite: type: object required: [quote, original_locale, author_name] properties: quote: { $ref: "#/components/schemas/TranslatedField" } original_locale: { type: string, description: A supported language code. } author_name: { type: string, maxLength: 255 } author_role: { $ref: "#/components/schemas/TranslatedField" } avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=testimonials`. } client_id: { type: integer, nullable: true } project_id: { type: integer, nullable: true } service_id: { type: integer, nullable: true } is_public: { type: boolean } position: { type: integer, minimum: 0 } given_at: { type: string, format: date, nullable: true } TestimonialCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Testimonial" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Talk: type: object properties: id: { type: integer } locale: { type: string } title: { type: string } language: { type: string } summary: { type: string, nullable: true } event: { type: string } city: { type: string, nullable: true } held_at: { type: string, format: date, nullable: true } video_url: { type: string, format: uri, nullable: true } slides_url: { type: string, format: uri, nullable: true } member_id: { type: integer, nullable: true } project_id: { type: integer, nullable: true } is_public: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } TalkWrite: type: object required: [title, language, event] properties: title: { type: string, maxLength: 255, description: Not translated. } language: { type: string, description: A supported language code. } summary: { $ref: "#/components/schemas/TranslatedField" } event: { type: string, maxLength: 255 } city: { type: string, nullable: true, maxLength: 120 } held_at: { type: string, format: date, nullable: true } video_url: { type: string, format: uri, nullable: true } slides_url: { type: string, format: uri, nullable: true } member_id: { type: integer, nullable: true } project_id: { type: integer, nullable: true } is_public: { type: boolean } TalkCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Talk" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Call: type: object properties: id: { type: integer } locale: { type: string } title: { type: string, nullable: true } slug: { type: string, nullable: true } description: { type: string, nullable: true } what_you_get: { type: string, nullable: true } status: { type: string, enum: [open, filled, closed] } hours_per_week: { type: integer, nullable: true } duration_weeks: { type: integer, nullable: true } seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } opens_at: { type: string, format: date, nullable: true } closes_at: { type: string, format: date, nullable: true } project_id: { type: integer, nullable: true } technology_ids: { type: array, items: { type: integer } } applications_count: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." } CallWrite: type: object required: [title, slug] properties: title: { $ref: "#/components/schemas/TranslatedField" } slug: allOf: [{ $ref: "#/components/schemas/TranslatedField" }] description: Unique per language. description: { $ref: "#/components/schemas/TranslatedField" } what_you_get: { $ref: "#/components/schemas/TranslatedField" } status: { type: string, enum: [open, filled, closed], default: open } hours_per_week: { type: integer, nullable: true, minimum: 1, maximum: 40 } duration_weeks: { type: integer, nullable: true, minimum: 1 } seniority: { type: string, nullable: true, enum: [any, junior, mid, senior] } opens_at: { type: string, format: date, nullable: true } closes_at: { type: string, format: date, nullable: true } project_id: { type: integer, nullable: true } technology_ids: { type: array, items: { type: integer } } CallCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Call" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Application: type: object properties: id: { type: integer } kind: { type: string, enum: [contribute, request] } status: { type: string, enum: [new, contacted, accepted, declined, spam] } call_id: { type: integer, nullable: true } subject: { type: string, description: "The call's title, or a general enquiry." } name: { type: string } email: { type: string, format: email } organisation: { type: string, nullable: true } message: { type: string } hours_per_week: { type: integer, nullable: true } links: { type: object, additionalProperties: { type: string }, description: "The links the sender gave, keyed as they were sent." } locale: { type: string, description: The language it was written in. } notes: { type: string, nullable: true, description: Private to the collective. } handled_by: type: object nullable: true properties: id: { type: integer } name: { type: string } handled_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } deleted_at: { type: string, format: date-time, nullable: true } ApplicationWrite: type: object properties: status: { type: string, enum: [new, contacted, accepted, declined, spam] } notes: { type: string, nullable: true, maxLength: 10000 } ApplicationCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Application" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Redirect: type: object properties: id: { type: integer } from: { type: string } to: { type: string } status: { type: integer, enum: [301, 302] } locale: { type: string, nullable: true, description: Null applies in every language. } is_enabled: { type: boolean } automatic: { type: boolean, description: Left behind by a slug rename. } hits: { type: integer } last_used_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } RedirectWrite: type: object required: [from, to] properties: from: { type: string, maxLength: 500 } to: { type: string, maxLength: 500, description: Different from `from`. } status: { type: integer, enum: [301, 302], default: 301 } locale: { type: string, nullable: true } is_enabled: { type: boolean, default: true } RedirectCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Redirect" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } MissingUrl: type: object properties: id: { type: integer } path: { type: string } locale: { type: string } referrer: { type: string, nullable: true } internal_referrer: { type: boolean, description: "A page of our own links here — more urgent than someone else's stale link." } status: { type: string, enum: [pending, resolved, ignored] } hits: { type: integer } first_seen_at: { type: string, format: date-time, nullable: true } last_seen_at: { type: string, format: date-time, nullable: true } resolved_at: { type: string, format: date-time, nullable: true } MissingUrlWrite: type: object required: [status] properties: status: { type: string, enum: [pending, ignored] } MissingUrlCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/MissingUrl" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Repository: type: object properties: id: { type: integer } provider: { type: string, enum: [gitea, github, gitlab] } full_name: { type: string, description: "`owner/name`." } project_id: { type: integer, nullable: true } is_primary: { type: boolean } show_publicly: { type: boolean } html_url: { type: string, nullable: true } description: { type: string, nullable: true } homepage: { type: string, nullable: true } default_branch: { type: string, nullable: true } primary_language: { type: string, nullable: true } license: { type: string, nullable: true } topics: { type: array, items: { type: string } } stars: { type: integer } forks: { type: integer } open_issues_count: { type: integer } is_archived: { type: boolean } pushed_at: { type: string, format: date-time, nullable: true } last_synced_at: { type: string, format: date-time, nullable: true } sync_error: { type: string, nullable: true } sync_failures: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } RepositoryWrite: type: object required: [provider, full_name] properties: provider: { type: string, enum: [gitea, github, gitlab] } full_name: { type: string, maxLength: 255, description: "`owner/name`, unique per provider." } project_id: { type: integer, nullable: true } is_primary: { type: boolean } show_publicly: { type: boolean } RepositoryCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Repository" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Setting: type: object properties: key: { type: string } value: { nullable: true, description: Any JSON value. } updated_at: { type: string, format: date-time, nullable: true } Maintenance: type: object properties: enabled: { type: boolean, description: False once `until` has passed. } message: { type: object, additionalProperties: { type: string }, description: Language → text shown to visitors. } until: { type: string, format: date-time, nullable: true } started_at: { type: string, format: date-time, nullable: true } Revision: type: object properties: id: { type: integer } event: { type: string, enum: [created, updated, published, unpublished, deleted, restored, erased] } author: type: object properties: id: { type: integer, nullable: true } name: { type: string } changes: type: object description: "Field → `{old, new}`. Translatable fields carry every language." additionalProperties: type: object properties: old: { nullable: true } new: { nullable: true } created_at: { type: string, format: date-time } RevisionCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Revision" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } ReviewComment: type: object properties: id: { type: integer } event: { type: string, enum: [requested, returned, published, commented] } body: { type: string, nullable: true } author: type: object properties: id: { type: integer, nullable: true } name: { type: string, nullable: true } created_at: { type: string, format: date-time } ReviewCommentCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/ReviewComment" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } ReviewState: type: object properties: type: { type: string, enum: [pages, projects, services] } id: { type: integer } title: { type: string, nullable: true } published: { type: boolean, description: On the site right now. } published_at: { type: string, format: date-time, nullable: true, description: In the future when scheduled. } awaiting_review: { type: boolean } requested_at: { type: string, format: date-time, nullable: true } requested_by: { $ref: "#/components/schemas/UserRef" } reviewer: { $ref: "#/components/schemas/UserRef" } UserRef: type: object nullable: true properties: id: { type: integer } name: { type: string } PreviewLinks: type: object properties: links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. } expires_at: { type: string, format: date-time } HealthFinding: type: object properties: key: { type: string, example: missing-translations } severity: { type: string, enum: [ok, warning, error] } count: { type: integer } title: { type: string } description: { type: string } samples: { type: array, items: { type: string }, description: Up to three examples. } panel_url: { type: string, nullable: true, description: Where to fix it in the panel. } PendingTranslation: type: object properties: type: { type: string, enum: [pages, projects, services, calls] } id: { type: integer } title: { type: string } missing: { type: array, items: { type: string }, description: Languages with nothing written. } stale: { type: object, additionalProperties: { type: string, format: date-time }, description: Language → since when it has been behind. } BatchOutcome: type: object properties: data: type: array items: type: object properties: index: { type: integer } method: { type: string } path: { type: string } status: { type: integer, description: The HTTP status the operation answered with. } body: { nullable: true, description: What the operation answered. } meta: type: object properties: committed: { type: boolean, description: False when an atomic run or a dry run undid everything. } succeeded: { type: integer } failed: { type: integer } dry_run: { type: boolean, nullable: true } ApiToken: type: object properties: id: { type: integer } name: { type: string } prefix: { type: string, description: "The first characters, to tell tokens apart." } abilities: { type: array, items: { type: string } } owner: type: object properties: id: { type: integer } name: { type: string, nullable: true } status: { type: string, enum: [active, expired, revoked] } expires_at: { type: string, format: date-time } last_used_at: { type: string, format: date-time, nullable: true } revoked_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } ApiTokenCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/ApiToken" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Webhook: type: object properties: id: { type: integer } name: { type: string } url: { type: string, format: uri } events: { type: array, items: { type: string } } include_personal_data: { type: boolean } is_active: { type: boolean } consecutive_failures: { type: integer } last_delivered_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } WebhookWrite: type: object required: [name, url, events] properties: name: { type: string, maxLength: 120 } url: { type: string, format: uri, description: "HTTPS, to a public address." } events: type: array items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] } secret: { type: string, minLength: 16, description: On creation only; generated when left out. } rotate_secret: { type: boolean, description: On update only. } include_personal_data: { type: boolean, default: false } is_active: { type: boolean } WebhookCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Webhook" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } WebhookDelivery: type: object properties: id: { type: integer } uuid: { type: string, description: "Sent as `X-Webhook-Delivery`." } event: { type: string } status: { type: string, enum: [pending, succeeded, failed] } attempts: { type: integer } response_status: { type: integer, nullable: true } error: { type: string, nullable: true } payload: { type: object, additionalProperties: true } delivered_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } WebhookDeliveryCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/WebhookDelivery" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } MediaFor: type: string enum: [pages, technologies, projects, members, clients, testimonials] default: pages description: The kind of record the image is for. Technology: type: object properties: id: { type: integer } locale: { type: string, description: The language the prose below was resolved into. } name: { type: string, description: A proper noun — the same in every language. } slug: { type: string } description: { type: string, nullable: true } radar_note: { type: string, nullable: true, description: Why it sits in its ring. } category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] } radar_ring: type: string enum: [adopt, trial, assess, hold] nullable: true description: Null keeps it off the radar; projects can still use it. color: { type: string, nullable: true, example: "#DD0031" } logo: type: string nullable: true description: The stored path — what `POST /v1/media` answered — and what a client PATCHes back. logo_media: allOf: [{ $ref: "#/components/schemas/Image" }] nullable: true readOnly: true website: { type: string, nullable: true, format: uri } position: { type: integer } projects_count: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } translations: type: object nullable: true description: Every language of `description` and `radar_note`, with `?with=translations`. TechnologyWrite: type: object required: [name, slug] properties: name: { type: string, maxLength: 120 } slug: { type: string, maxLength: 120, pattern: "^[A-Za-z0-9_-]+$" } description: { $ref: "#/components/schemas/TranslatedField" } radar_note: { $ref: "#/components/schemas/TranslatedField" } category: type: string enum: [language, framework, datastore, infrastructure, tool, practice] default: tool radar_ring: { type: string, enum: [adopt, trial, assess, hold], nullable: true } color: { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" } logo: type: string nullable: true description: A path from `POST /v1/media`, uploaded with `for=technologies`. website: { type: string, format: uri, nullable: true, maxLength: 255 } position: { type: integer, minimum: 0 } TechnologyCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Technology" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Media: type: object properties: id: { type: integer } path: type: string description: What a record stores, e.g. as a page's `featured_image`. example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg url: { type: string, format: uri, description: Absolute. } mime: { type: string, nullable: true, example: image/jpeg } size: { type: integer, nullable: true, description: Bytes. } width: { type: integer, nullable: true } height: { type: integer, nullable: true } processed: type: boolean description: | Whether the queue has derived the smaller versions and the placeholder yet. The image serves either way. error: { type: string, nullable: true, description: "Why processing failed, when it did." } referenced: type: boolean nullable: true description: Whether anything points at it. Sent by the library only, not on upload. created_at: { type: string, format: date-time } MediaCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Media" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } Category: type: object properties: id: { type: integer } locale: { type: string, example: es } name: { type: string, nullable: true } slug: { type: string, nullable: true } description: { type: string, nullable: true } color: { type: string, nullable: true, example: "#4f46e5" } position: { type: integer } is_visible: { type: boolean } pages_count: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } translations: type: object nullable: true description: Present only when `?with=translations` was passed. additionalProperties: type: object additionalProperties: { type: string } CategoryWrite: type: object required: [name, slug] properties: name: { $ref: "#/components/schemas/TranslatedField" } slug: { $ref: "#/components/schemas/TranslatedField" } description: { $ref: "#/components/schemas/TranslatedField" } color: { type: string, pattern: '^#[0-9a-fA-F]{6}$' } position: { type: integer, minimum: 0 } is_visible: { type: boolean } User: type: object properties: id: { type: integer, description: Local id. } oauth_id: { type: integer, nullable: true, description: This person's id on the identity server. } name: { type: string } email: { type: string, format: email } avatar_url: { type: string, nullable: true } locale: { type: string, nullable: true, enum: [es, en, ca] } is_active: { type: boolean } last_login_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } roles: type: array items: { type: string } description: Local roles — what this person may do in this app. example: [editor] permissions: type: array items: { type: string } nullable: true description: Effective local permissions. Returned by `/v1/me` only. remote_roles: type: array items: { type: string } description: | Roles reported by the identity server. Informational only; they grant nothing here. Role: type: object properties: id: { type: integer } name: { type: string, example: editor } guard_name: { type: string, example: web } users_count: { type: integer } permissions: type: array items: { type: string } created_at: { type: string, format: date-time } PasswordGrantRequest: type: object required: [grant_type, username, password] properties: grant_type: { type: string, enum: [password] } username: { type: string, format: email } password: { type: string, format: password } scope: { type: string, example: "account:read" } RefreshGrantRequest: type: object required: [grant_type, refresh_token] properties: grant_type: { type: string, enum: [refresh_token] } refresh_token: { type: string } TokenResponse: type: object properties: access_token: { type: string } refresh_token: { type: string, nullable: true } token_type: { type: string, example: Bearer } expires_at: { type: string, format: date-time, nullable: true } scopes: type: array items: { type: string } user: type: object properties: id: { type: integer } name: { type: string } email: { type: string } roles: type: array items: { type: string } permissions: type: array items: { type: string } PaginationMeta: type: object description: | Laravel's own paginator shape, sent as-is — nothing on this side trims it down, so this describes exactly what `->paginate()` sends rather than a curated subset of it. properties: current_page: { type: integer } from: { type: integer, nullable: true, description: Null on an empty page. } last_page: { type: integer } links: type: array description: One entry per page number shown by a "1 2 3 … 9" control, plus prev/next. items: type: object properties: url: { type: string, nullable: true } label: { type: string, description: "Already translated — e.g. «Anterior», not «Previous»." } page: { type: integer, nullable: true } active: { type: boolean } path: { type: string } per_page: { type: integer } to: { type: integer, nullable: true, description: Null on an empty page. } total: { type: integer } PaginationLinks: type: object description: The four addresses a client actually navigates with, ready to use verbatim. properties: first: { type: string } last: { type: string } prev: { type: string, nullable: true } next: { type: string, nullable: true } PageCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Page" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" } CategoryCollection: type: object properties: data: type: array items: { $ref: "#/components/schemas/Category" } links: { $ref: "#/components/schemas/PaginationLinks" } meta: { $ref: "#/components/schemas/PaginationMeta" }