Developers

The Stackportfolio API lets your own scripts and tools read and update your profile and projects. It is a JSON API over HTTPS, described by an OpenAPI 3.1 document.

Developer Platform Overview

Stackportfolio provides a unified developer platform accessible via standard REST endpoints and the Model Context Protocol (MCP). Authenticate requests using Personal API keys with Bearer authorization.

Base URL: https://stackportfol.io/api/v1

Get an API key

Create a key on your API keys page. Give it only the scopes the tool needs and an expiry between 30 and 365 days. The key is shown once, so store it somewhere safe; you can revoke it at any time.

Authenticate

Send the key with every request, in either of these headers. Cookies are never used. A key that is missing, wrong, expired or revoked always gets the same 401.

Scopes

Each operation needs one scope, shown in the reference below. A key without it gets a 403 with the code insufficient_scope. GET /me works with any valid key and lists the key's scopes.

  • profile:read Read your profile
  • profile:write Update your profile
  • projects:read Read your projects
  • projects:write Create and update your projects
  • projects:delete Delete your projects (irreversible)

Model Context Protocol (MCP)

Stackportfolio is also an MCP server. Point an MCP client such as Claude at the address below: it registers itself, asks you to sign in and approve the scopes it wants, and then calls the same operations as the API, with your permissions. No API key is needed.

MCP server: https://stackportfol.io/mcp

Web MCP Browser Integration

Stackportfolio exposes client-side Web MCP discovery via navigator.modelContext. Browser-based AI agents can discover tools natively in the user's active session without passing manual credentials.

Projects API

List, create, and manage projects. Pass scope=mine to retrieve projects you own or maintain, or scope=visible to browse public projects.

Contribution Stories API

Publish structured "How I worked on this" stories detailing problems, engineering approaches, corrections, and practices.

Rate limits

Each account may make 3 requests per second, 30 per minute, 300 per hour and 1,000 per day, counted across all of its keys. Past any of these, requests get a 429 with the code rate_limited and a Retry-After header giving the seconds to wait. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.

Errors

Every error has the same JSON shape. The code is stable and safe to branch on; the message is written for people and may change.

unauthorized HTTP 401
The key is missing, malformed, unknown, expired or revoked.
insufficient_scope HTTP 403
The key lacks the scope the operation needs.
not_found HTTP 404
There is no such resource, or it is not one you may see or change.
validation_failed HTTP 400
The request is not valid. details lists each problem with its field path.
conflict HTTP 409
The change conflicts with the current state, such as adding a technology twice.
rate_limited HTTP 429
The key has used its hourly allowance.
internal HTTP 500
Something went wrong on our side. Retrying later is safe for reads.

Endpoint reference

Generated from the same schemas the server validates requests with. The reference is in English.

Download openapi.json
GET /openapi.json

This document

Scope: no key needed

Returns: 200 β€” The OpenAPI 3.1 document. object
GET /docs/search

Search developer documentation

Search documentation sections, guides, REST API endpoints, schemas, and MCP tools.

Scope: profile:read

Parameters

?query string
Search query to look up in documentation.
?limit integer
Maximum number of results to return.; min 1; max 50; default 10
Returns: 200 β€” Matching documentation sections. object
GET /me

The key owner and its scopes

Scope: any valid key

Returns: 200 β€” Who the key belongs to. Me
GET /profile

The caller's tech profile

Scope: profile:read

Returns: 200 β€” The profile. Profile
PATCH /profile

Update the caller's tech profile

A partial update merged onto the stored profile; null clears a field. A GET body can be sent back as is.

Scope: profile:write

Request body

title string
min length 1; max length 100
experienceLevel "junior" | "mid" | "senior" | "lead"
githubUsername string
max length 39
gitlabUsername string
max length 39
bitbucketUsername string
max length 39
seekingEmployment boolean
openToOpportunities boolean
preferredSalaryMin integer
min 0; max 100000000
preferredSalaryMax integer
min 0; max 100000000
salaryCurrency "USD" | "EUR" | "GBP" | "CAD"
availableDate string
yyyy-mm-dd, or a full ISO date-time.
Returns: 200 β€” The updated profile. Profile
GET /projects

List projects

mine is every project the caller is on, private ones and open invitations included; visible is every project they may read.

Scope: projects:read

Parameters

?scope "mine" | "visible"
Which projects to list.; default "mine"
?limit integer
Page size, clamped to 1–100.; min 1; max 100; default 20
?offset integer
Rows to skip.; min 0; default 0
Returns: 200 β€” A page of projects. { data: ProjectSummary[]; pagination: Pagination }
POST /projects

Create a project

The caller becomes its OWNER. Visibility defaults to private. Listed technologies are credited to the caller. Past the active-project limit the project is still created, with deactivatedAt set. Project creation has its own hourly and daily limit (429).

Scope: projects:write

Request body

name string required
min length 1; max length 200
description string
max length 5000; default ""
repositoryUrl string
max length 500; default ""
deployedUrl string
max length 500; default ""
organisation string
max length 200; default ""
technologies (string | { name: string; version: string | null })[] | string
Credited to the caller. At most 50.
contributors (string | { name: string; version: string | null })[] | string
Usernames or emails of existing users, invited unaccepted.
visibility "public" | "unlisted" | "private"
default "private"
saveAsDraft boolean
default false
parentId string (uuid) | null
Create this project as a part of another.
Returns: 201 β€” The new project. Project
GET /projects/{id}

Get a project

Scope: projects:read

Parameters

{id} string required
Project id (uuid).
Returns: 200 β€” The project. Project
PATCH /projects/{id}

Update a project

A partial update; null clears a field. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).

Request body

name string
min length 1; max length 200
description string
max length 5000
repositoryUrl string
max length 500
deployedUrl string
max length 500
organisation string | { name: string }
technologies (string | { name: string; version: string | null })[] | string
The complete new list, at most 50. Omit it to keep the current technologies.
formerRows string[]
former string[]
replacedBy string[]
visibility "public" | "unlisted" | "private"
isArchived boolean
isActive boolean
The inverse of isArchived; either may be sent.
deactivated boolean
Owner only. false activates the project, which is a 409 when the owner is already at their active-project limit.
Returns: 200 β€” The updated project. Project
DELETE /projects/{id}

Delete a project

Irreversible. Its technologies, contributors, media and evidence go with it and every credited contributor is rescored; its parts are kept and become root projects. Needs the separate projects:delete scope, which projects:write does not imply. Only the accepted OWNER may delete; anyone else gets the same 404 as for a missing project.

Scope: projects:delete

Parameters

{id} string required
Project id (uuid).
Returns: 204 β€” Deleted.
GET /projects/{id}/technologies

List a project's technologies

Scope: projects:read

Parameters

{id} string required
Project id (uuid).
?limit integer
Page size, clamped to 1–100.; min 1; max 100; default 20
?offset integer
Rows to skip.; min 0; default 0
Returns: 200 β€” A page of technologies. { data: ProjectTechnology[]; pagination: Pagination }
POST /projects/{id}/technologies

Add a technology

Credits every accepted contributor with it. A project lists at most 50 technologies; one more is a 409. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).

Request body

name string required
min length 1; max length 100
version string | null
The major or major.minor version the project used, like "5" or "3.12"; null when unknown.
former boolean
Whether the project moved off this technology.
replacedBy string (uuid) | null
The id of the technology that replaced it, which the project must list and which must not be this one. Needs former: true; null clears it, as does former: false.
Returns: 201 β€” The attached technology. ProjectTechnology
GET /projects/{id}/media

List a project's media

Lists all media items for a project in gallery order.

Scope: projects:read

Parameters

{id} string required
Project id (uuid).
?limit integer
Page size, clamped to 1–100.; min 1; max 100; default 20
?offset integer
Rows to skip.; min 0; default 0
Returns: 200 β€” A page of media items. { data: ProjectMedia[]; pagination: Pagination }
POST /projects/{id}/media

Add an image to a project

Optimises the image server-side and appends it to the project's gallery; the first image becomes the cover. Takes a JSON body with base64 data, or multipart/form-data with a file part and an optional description. The decoded image may be at most 5 MB. A project holds a limited number of images set by its owner's plan; one more is a 409. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).

Request body

data string required
The image, base64-encoded (a data: URL is accepted). PNG, JPEG, WebP or a still GIF; at most 6 MB of base64 in a JSON body. It is resized to at most 2048px, converted to WebP and stripped of metadata. The REST endpoint also takes multipart/form-data with the file in a file field.; min length 1
description string
Caption and alt text for the image.; max length 200
Returns: 201 β€” The stored image. ProjectMedia
POST /projects/{id}/media/uploads

Create a single-use upload URL for a project image

Generates a short-lived (10 min), single-use HMAC-signed PUT URL to upload a project image directly without base64 encoding. Accepts PNG, JPEG, or WebP up to 5 MB. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).

Request body

filename string required
Original filename for the image, including extension.; min length 1
contentType "image/png" | "image/jpeg" | "image/webp" required
The MIME type of the image to upload (image/png, image/jpeg, or image/webp).
description string
Caption and alt text for the image.; max length 200
altText string
Alt text for the image (defaults to description if omitted).; max length 200
Returns: 201 β€” The signed upload URL and required headers. ProjectMediaUpload
PUT /uploads/{token}

Upload an image to a signed URL

Authorised by the signed URL alone, no key. The body is the raw PNG, JPEG or WebP bytes (not multipart) with the Content-Type from createProjectMediaUpload, at most 5 MB. The URL works once and expires after 10 minutes; a used URL is a 409, an expired one a 410, an oversized body a 413. Responds with the stored image.

Scope: no key needed

Parameters

{token} string required
The signed token from the upload URL.
Returns: 201 β€” The stored image. ProjectMedia
PATCH /projects/{id}/media/{mediaId}

Update a project's media item

Updates metadata (caption, alt text), position/sort order, or cover status for a media item. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{mediaId} string required
Media item id (uuid).

Request body

caption string | null
Caption for the image; null clears it.
altText string | null
Alt text for the image; null clears it.
position integer
New 0-indexed position in the gallery.; min 0; max 9007199254740991
sortOrder integer
Alias for position.; min 0; max 9007199254740991
makeCover boolean
Whether this media item should become the project cover.
Returns: 200 β€” The updated media item. ProjectMedia
PUT /projects/{id}/media/{mediaId}

Replace a project's media image

Replaces the image file with a new upload (base64 data or multipart file). Optimises to WebP with responsive variants and deletes the old stored objects. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{mediaId} string required
Media item id (uuid).

Request body

data string required
The replacement image, base64-encoded. At most 6 MB of base64; data: URL accepted.; min length 1
description string
Optional caption and alt text for the replacement image.; max length 200
Returns: 200 β€” The updated media item. ProjectMedia
DELETE /projects/{id}/media/{mediaId}

Remove a project's media item

Deletes the media item and its stored files (including variants), repairing cover and position ordering. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{mediaId} string required
Media item id (uuid).
Returns: 204 β€” The media item was removed.
PATCH /projects/{id}/technologies/{technologyId}

Update a technology on a project

Marks a technology former (moved off), optionally naming the technology that replaced it. Send only the keys to change. A former technology still counts for proficiency, so nobody is rescored. A technology the project does not list and a project the caller may not edit are the same 404. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{technologyId} string required
Technology id (uuid).

Request body

version string | null | string
The version to set; null or "" clears it.
former boolean
Whether the project moved off this technology.
replacedBy string (uuid) | null
The id of the technology that replaced it, which the project must list and which must not be this one. Needs former: true; null clears it, as does former: false.
Returns: 200 β€” The technology. ProjectTechnology
DELETE /projects/{id}/technologies/{technologyId}

Remove a technology

Deletes the evidence it gave on this project and rescores its contributors. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{technologyId} string required
Technology id (uuid).
Returns: 204 β€” Removed.
GET /projects/{id}/contributors

List a project's contributors

Open invitations included. No email addresses.

Scope: projects:read

Parameters

{id} string required
Project id (uuid).
?limit integer
Page size, clamped to 1–100.; min 1; max 100; default 20
?offset integer
Rows to skip.; min 0; default 0
Returns: 200 β€” A page of contributors. { data: Contributor[]; pagination: Pagination }
POST /projects/{id}/contributors

Invite a contributor

Invites an existing user by username or email; they are notified and must accept. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).

Request body

identifier string required
min length 1; max length 254
role "MAINTAINER" | "CONTRIBUTOR"
default "CONTRIBUTOR"
Returns: 201 β€” The invitation. Contributor
PATCH /projects/{id}/contributors/{userId}

Change a contributor's role

The owner's role cannot be changed (409). Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{userId} string required
The contributor's user id.

Request body

role "MAINTAINER" | "CONTRIBUTOR" required
Returns: 200 β€” The contributor. Contributor
DELETE /projects/{id}/contributors/{userId}

Remove a contributor

Also cancels an open invitation, deletes their evidence on this project and rescores them. Only an accepted OWNER or MAINTAINER may write; anyone else gets the same 404 as for a missing project.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{userId} string required
The contributor's user id.
Returns: 204 β€” Removed.
PUT /projects/{id}/contributors/{userId}/technologies

Set which technologies a contributor used

Replaces one accepted contributor's technology credits on this project and rescores them; proficiency stays computed. The caller may set their own, and an accepted OWNER or MAINTAINER anyone's but the owner's. A pending invitee, a target the caller may not change, and a project the caller is not on are the same 404.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{userId} string required
The contributor's user id.

Request body

technologyIds (string (uuid))[] required
The whole set, replacing the current one. Each must be one of the project's technologies.
Returns: 200 β€” The contributor. Contributor
PUT /projects/{id}/contributors/{userId}/skills

Set the skills a contributor used

Replaces the caller's own skills on this project and recomputes their skill levels. userId must be the caller: nobody records skills for someone else, and any other id is a 404.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
{userId} string required
The contributor's user id.

Request body

skills string[] required
The whole list, replacing the current one. At most 10, each at most 50 characters; repeats are dropped case-insensitively.
Returns: 200 β€” The contributor. Contributor
GET /stories

List contribution stories

Every "How I worked on this" account on the projects the caller may see, newest project activity first. mine keeps only the caller’s own stories.

Scope: projects:read

Parameters

?scope "all" | "mine"
Which stories to list.; default "all"
?limit integer
Page size, clamped to 1–100.; min 1; max 100; default 20
?offset integer
Rows to skip.; min 0; default 0
Returns: 200 β€” A page of stories. { data: Story[]; pagination: Pagination }
GET /projects/{id}/stories

List a project's contribution stories

Every "How I worked on this" account written on the project, as its page renders them. mine keeps only the caller’s own.

Scope: projects:read

Parameters

{id} string required
Project id (uuid).
?scope "all" | "mine"
Which stories to list.; default "all"
?limit integer
Page size, clamped to 1–100.; min 1; max 100; default 20
?offset integer
Rows to skip.; min 0; default 0
Returns: 200 β€” A page of stories. { data: Story[]; pagination: Pagination }
GET /projects/{id}/stories/{userId}

Get one contributor's story

One contributor's story on a project the caller may see. Any author's story is readable, as the project page renders it; pass the caller's own id from /me to read theirs.

Scope: projects:read

Parameters

{id} string required
Project id (uuid).
{userId} string required
The contributor's user id.
Returns: 200 β€” The story. Story
PUT /projects/{id}/story

Write the caller's story

Writes the caller’s own "How I worked on this" account, replacing any previous one. Every accepted contributor may write theirs, and nobody β€” owner included β€” may write anybody else’s: a project the caller is not on and a missing one are the same 404. Declaring a practice makes corrections mandatory, which is the anti-AI-washing rule the web editor enforces.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).

Request body

problem string required
min length 40; max length 600
approach string required
min length 40; max length 800
corrections string
max length 800; default ""
hindsight string
max length 400; default ""
practices ({ tool: string; role: "EXPLORED_OPTIONS" | "WROTE_FIRST_DRAFT" | "REVIEWED_MY_WORK" | "DID_REPETITIVE_PART" | "BUILT_END_TO_END" | "SOMETHING_ELSE"; askedFor: string })[]
default []
links ({ label: string; url: string })[]
default []
Returns: 200 β€” The saved story. Story
DELETE /projects/{id}/story

Delete the caller's story

Deletes the caller’s own story on the project. The contribution itself is untouched: only the write-up goes. Only ever the caller’s own story, so projects:write is enough.

Scope: projects:write

Parameters

{id} string required
Project id (uuid).
Returns: 204 β€” Deleted.

Schemas

Error

error { code: "unauthorized" | "insufficient_scope" | "not_found" | "validation_failed" | "conflict" | "rate_limited" | "internal"; message: string; details: any } required

Pagination

limit integer required
min -9007199254740991; max 9007199254740991
offset integer required
min -9007199254740991; max 9007199254740991
total integer required
min -9007199254740991; max 9007199254740991

Me

userId string required
username string,null required
scopes ("profile:read" | "profile:write" | "projects:read" | "projects:write" | "projects:delete")[] required
via "api_key" | "oauth" required

Profile

userId string required
title string,null required
experienceLevel string,null required
githubUsername string,null required
gitlabUsername string,null required
bitbucketUsername string,null required
seekingEmployment boolean required
openToOpportunities boolean required
preferredSalaryMin number,null required
preferredSalaryMax number,null required
salaryCurrency "USD" | "EUR" | "GBP" | "CAD" | null required
availableDate string (date-time) | null required
createdAt string (date-time) required
updatedAt string (date-time) required
areas ({ slug: string; name: string; group: "languages" | "frontend" | "backend" | "data" | "infrastructure" | "developer-tooling" | "quality" | "mobile" | "ai" | "other"; projectCount: number; level: number; technologies: ({ id: string; name: string })[] })[] required
The categories your project credits fall into (one per technology), most projects first. Computed, read-only.
indicator { label: "specialist" | "generalist" | null; breadth: number; depth: number; topArea: { slug: "languages" | "frontend" | "backend" | "data" | "infrastructure" | "developer-tooling" | "quality" | "mobile" | "ai" | "other"; name: string }; projectCount: number } | null required
null with no areas, or when you have hidden it in settings. Computed, read-only.

ProjectSummary

id string (uuid) required
name string required
description string,null required
repositoryUrl string,null required
provider "github" | "gitlab" | "bitbucket" | null required
image string,null required
images string[] required
Card images, cover first.
isActive boolean required
isPublic boolean required
starCount integer required
min -9007199254740991; max 9007199254740991
lastActivityDate string (date-time) required
createdAt string (date-time) required
role "OWNER" | "MAINTAINER" | "CONTRIBUTOR" | null required
The caller's role on the project, if any.
accepted boolean,null required
Whether the caller has accepted their invitation.
pinnedPosition integer | null required
Where the caller pinned the project on their profile (1 is first), or null. Only scope=mine sets it, and that list is already ordered pins first.

Project

id string (uuid) required
name string required
description string,null required
repositoryUrl string,null required
deployedUrl string,null required
provider string,null required
image string,null required
isActive boolean required
isPublic boolean required
deactivatedAt string (date-time) | null required
Set while the project is outside its owner's active-project limit. An inactive project is private to everyone but its contributors.
visibility "public" | "unlisted" | "private" required
effectiveVisibility "public" | "unlisted" | "private" required
The visibility once every ancestor is taken into account.
starCount integer required
min -9007199254740991; max 9007199254740991
commitCount integer required
min -9007199254740991; max 9007199254740991
viewCount integer required
min -9007199254740991; max 9007199254740991
lastActivityDate string (date-time) required
createdAt string (date-time) required
viewerRole "OWNER" | "MAINTAINER" | "CONTRIBUTOR" | null required
The caller's accepted role, if any.
organisation { id: string (uuid); name: string; slug: string; isApproved: boolean; isVerified: boolean; logoUrl: string,null } | null required
This project's organisation, or its nearest ancestor's.
ancestors ({ id: string (uuid); name: string })[] required
The parent chain, root first, names only.
technologies ({ id: string (uuid); name: string; version: string | null; isApproved: boolean; former: boolean; replacedBy: { id: string (uuid); name: string } | null; categories: ({ slug: string; name: string })[] })[] required
media ({ id: string (uuid); kind: "image" | "video_embed"; url: string; posterUrl: string,null; embedProvider: "youtube" | "vimeo" | "loom" | null; embedId: string,null; caption: string,null; altText: string,null; position: integer; isCover: boolean })[] required

Technology

id string (uuid) required
name string required

ProjectTechnology

id string (uuid) required
name string required
version string | null required
The major or major.minor version the project used, like "5" or "3.12"; null when unknown.
isApproved boolean required
False while awaiting admin approval; visible to project members.
former boolean required
The project moved off it. It still counts as evidence for proficiency.
replacedBy { id: string (uuid); name: string } | null required
What replaced it: another technology on this project. Only set when former.
categories ({ slug: string; name: string })[] required

ProjectMedia

id string (uuid) required
kind "image" | "video_embed" required
url string required
posterUrl string,null required
embedProvider "youtube" | "vimeo" | "loom" | null required
embedId string,null required
caption string,null required
altText string,null required
position integer required
Order in the gallery, 0-indexed.; min -9007199254740991; max 9007199254740991
isCover boolean required

ProjectMediaUpload

uploadUrl string required
The single-use PUT URL to upload the raw image to.
method string required
The HTTP method to use for the upload.
expiresAt string required
When the upload URL expires (ISO 8601).
maxBytes integer required
Maximum allowed size of the upload in bytes.; min -9007199254740991; max 9007199254740991
headers object required
Required request headers to include on the upload.

Contributor

userId string required
name string required
username string,null required
image string,null required
role "OWNER" | "MAINTAINER" | "CONTRIBUTOR" required
accepted boolean required
invitedAt string (date-time) required
technologies ({ id: string (uuid); name: string })[] required
Which of the project's technologies this person used here. Empty while pending.
skills ({ id: string (uuid); name: string })[] required
The skills this person recorded for their work here. Empty while pending.

Story

projectId string (uuid) required
id string required
userId string required
author { name: string,null; username: string,null; image: string,null } required
problem string required
approach string required
corrections string,null required
hindsight string,null required
practices ({ technologyId: string (uuid); tool: string; toolApproved: boolean; role: "EXPLORED_OPTIONS" | "WROTE_FIRST_DRAFT" | "REVIEWED_MY_WORK" | "DID_REPETITIVE_PART" | "BUILT_END_TO_END" | "SOMETHING_ELSE"; askedFor: string })[] required
links ({ label: string; url: string })[] required
updatedAt string (date-time) required