REST API documentation
Every public Stackness route, 173 endpoints in 22 sections, from authentication and stacks to moves, feeds and discovery.
Base URL
https://stackness.dev/api/v1All endpoint paths below are relative to this base URL.
Authentication
Stackness supports two authentication methods:
Obtain a token via POST /auth/login or POST /auth/register. Pass it in the Authorization header:
Authorization: Bearer eyJhbGciOiJI...Create an API key (prefixed with sfk_) via the API keys endpoints or in settings. Pass it in the Authorization header:
Authorization: Bearer sfk_abc123...OAuth flows (GitHub, Google) are also available - see the authentication section below for redirect-based endpoints.
Pagination
List endpoints use cursor-based pagination. Pass cursor and limit as query parameters.
GET /api/v1/tools?limit=20&cursor=abc123
{
"tools": [...],
"next_cursor": "def456",
"has_more": true
}When has_more is false, you have reached the end of the list.
Error format
All errors return a JSON object with error and code fields:
{
"error": "resource not found",
"code": "NOT_FOUND"
}
Common HTTP status codes:
400 - Bad Request (validation error)
401 - Unauthorized (missing or invalid token)
403 - Forbidden (insufficient permissions)
404 - Not Found
409 - Conflict (duplicate resource)
429 - Too Many Requests (rate limited)
500 - Internal Server ErrorRate limits
| Tier | Limit | Scope |
|---|---|---|
| Public endpoints | 900 req/min | Per IP address |
| Authenticated endpoints | 600 req/min | Per user |
| Auth endpoints (login, register) | 10 req/min | Per IP address |
| Session endpoints (refresh, logout) | 60 req/min | Per IP address |
When rate limited, the API returns HTTP 429 with a Retry-After header.
Authentication
13Register, log in, refresh tokens, and authenticate via OAuth providers.
Create a new user account with email and password. Returns access and refresh tokens and emails a link to verify the address. The account can use the API straight away, but its email must be verified within 72 hours of signing up: `email_verification_deadline` in the user object says when, and past it login and refresh return 403 with code EMAIL_NOT_VERIFIED until the address is verified.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| string | Yes | Email address | |
| password | string | Yes | Password (min 8 chars, 1 uppercase, 1 number, 1 special) |
| username | string | Yes | Unique username (3-30 chars, alphanumeric + hyphens) |
| display_name | string | No | Display name |
| product_updates | boolean | No | Consent to occasional product update emails. Defaults to false; true stores the consent with the time it was given |
| return_to | string | No | Same-site path the sign-up started from, such as /blog/{slug}. Only counted in the anonymous sign-up totals; never stored on the account. An unsafe value counts as none |
| preselected_tools | integer | No | How many tools the reader picked before signing up (0-50), for analytics only. The tools themselves are added later, after the member confirms them |
Response
{
"user": {
"id": 1,
"username": "janedoe",
"email": "jane@example.com",
"display_name": "Jane Doe",
"email_verified": false,
"email_verification_deadline": "2026-09-16T09:00:00Z",
"ask_product_updates": false
},
"access_token": "eyJhbGciOiJI...",
"refresh_token": "eyJhbGciOiJI..."
}curl -X POST https://stackness.dev/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"password": "SecurePass1!",
"username": "janedoe"
}'const res = await fetch("https://stackness.dev/api/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "jane@example.com",
password: "SecurePass1!",
username: "janedoe",
}),
});
const data = await res.json();Authenticate with email and password. Returns JWT access and refresh tokens. An account whose email is still unverified 72 hours after signing up gets 403 with code EMAIL_NOT_VERIFIED instead, and a fresh verification link is emailed to it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| string | Yes | Email address | |
| password | string | Yes | Account password |
Response
{
"user": {
"id": 1,
"username": "janedoe",
"email": "jane@example.com",
"email_verified": true
},
"access_token": "eyJhbGciOiJI...",
"refresh_token": "eyJhbGciOiJI..."
}curl -X POST https://stackness.dev/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "jane@example.com", "password": "SecurePass1!"}'const res = await fetch("https://stackness.dev/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "jane@example.com",
password: "SecurePass1!",
}),
});
const data = await res.json();Exchange a valid refresh token for a new access token. The old refresh token is invalidated. Returns 403 with code EMAIL_NOT_VERIFIED once an unverified account is past its 72-hour grace period.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | Yes | Current refresh token |
Response
{
"access_token": "eyJhbGciOiJI...",
"refresh_token": "eyJhbGciOiJI..."
}curl -X POST https://stackness.dev/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "eyJhbGciOiJI..."}'const res = await fetch("https://stackness.dev/api/v1/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
const data = await res.json();Invalidate the current refresh token, effectively logging the user out.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | Yes | Refresh token to invalidate |
Response
{
"message": "logged out"
}curl -X POST https://stackness.dev/api/v1/auth/logout \
-H "Content-Type: application/json" \
-d '{"refresh_token": "eyJhbGciOiJI..."}'await fetch("https://stackness.dev/api/v1/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});Redirects the user to GitHub for authorization. After consent, GitHub redirects back to the callback URL. The state GitHub sees is a bare UUID; return_to and tools stay on the server. If GitHub answers with an error, such as a cancelled sign-in, the frontend callback still gets both values back.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| return_to | string | No | Same-site path to come back to after signing in (query param, max 512 chars). Kept in the server-side state, never sent to GitHub, and returned by POST /api/v1/auth/oauth/exchange. An unsafe value counts as none and never fails the redirect |
| tools | string | No | Comma-separated tool ids the reader picked before signing in (query param). Positive integers only, duplicates dropped, at most 50 kept. Kept and returned the same way as return_to |
Response
302 Redirect to GitHub authorization page# Open in browser - this endpoint redirects
curl -v "https://stackness.dev/api/v1/auth/github?return_to=%2Fblog%2Fsome-post&tools=611,407"// Redirect the user in the browser
window.location.href =
"https://stackness.dev/api/v1/auth/github?return_to=%2Fblog%2Fsome-post&tools=611,407";Handles the OAuth callback from GitHub. Exchanges the authorization code for tokens and creates or links the user account.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | Authorization code from GitHub (query param) |
| state | string | Yes | CSRF state token (query param) |
Response
302 Redirect to frontend with tokens# This endpoint is called by GitHub, not directly
# GitHub redirects to: /api/v1/auth/github/callback?code=...&state=...// This callback is handled automatically by the OAuth flow
// After redirect, tokens are available via query paramsRedirects the user to Google for authorization. After consent, Google redirects back to the callback URL. The state Google sees is a bare UUID; return_to and tools stay on the server. If Google answers with an error, such as a cancelled sign-in, the frontend callback still gets both values back.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| return_to | string | No | Same-site path to come back to after signing in (query param, max 512 chars). Kept in the server-side state, never sent to Google, and returned by POST /api/v1/auth/oauth/exchange. An unsafe value counts as none and never fails the redirect |
| tools | string | No | Comma-separated tool ids the reader picked before signing in (query param). Positive integers only, duplicates dropped, at most 50 kept. Kept and returned the same way as return_to |
Response
302 Redirect to Google authorization page# Open in browser - this endpoint redirects
curl -v "https://stackness.dev/api/v1/auth/google?return_to=%2Fblog%2Fsome-post&tools=611,407"// Redirect the user in the browser
window.location.href =
"https://stackness.dev/api/v1/auth/google?return_to=%2Fblog%2Fsome-post&tools=611,407";Handles the OAuth callback from Google. Exchanges the authorization code for tokens and creates or links the user account.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | Authorization code from Google (query param) |
| state | string | Yes | CSRF state token (query param) |
Response
302 Redirect to frontend with tokens# This endpoint is called by Google, not directly
# Google redirects to: /api/v1/auth/google/callback?code=...&state=...// This callback is handled automatically by the OAuth flow
// After redirect, tokens are available via query paramsEmail a single-use password reset link to the given address. The response is the same generic 200 whether or not an account exists for that email, so the endpoint cannot be used to probe which emails are registered. OAuth-only accounts can complete the flow to set a password alongside their provider login.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| string | Yes | Email address of the account | |
| cf_turnstile_token | string | No | Cloudflare Turnstile captcha token, verified when captcha is enabled |
Response
{
"message": "If an account exists for that email, a password reset link has been sent."
}curl -X POST https://stackness.dev/api/v1/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email": "jane@example.com"}'const res = await fetch("https://stackness.dev/api/v1/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "jane@example.com" }),
});
const data = await res.json();Set a new password using the single-use token from a password reset email. On success all of the user's existing sessions are revoked, so every device must log in again. An invalid or expired token returns 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Single-use reset token from the emailed link |
| password | string | Yes | New password (min 8 chars) |
Response
{
"message": "password has been reset"
}curl -X POST https://stackness.dev/api/v1/auth/reset-password \
-H "Content-Type: application/json" \
-d '{"token": "a1b2c3d4...", "password": "NewSecurePass1!"}'const res = await fetch("https://stackness.dev/api/v1/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: resetToken, password: "NewSecurePass1!" }),
});
const data = await res.json();Confirm the account's email address with the single-use token from a verification email. No session is needed, so the link works on any device. A token expires after 72 hours; verifying at any time, including after the grace period has blocked login, unblocks the account. An invalid, expired or already used token returns 400 with code INVALID_VERIFICATION_TOKEN.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Single-use verification token from the emailed link |
Response
{
"message": "email address verified"
}curl -X POST https://stackness.dev/api/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token": "a1b2c3d4..."}'const res = await fetch("https://stackness.dev/api/v1/auth/verify-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: verificationToken }),
});
const data = await res.json();Email a fresh verification link to the signed-in user, invalidating the previous link. An account that is already verified gets the same 200 with email_verified set to true, and nothing is sent. Takes a JWT access token, not an API key. Limited to 5 requests per 10 minutes per user, and a new link goes out at most once every 2 minutes: a request inside that window returns 429 with code RATE_LIMITED.
Response
{
"message": "If your email address is not verified yet, a new verification link has been sent.",
"email_verified": false
}curl -X POST https://stackness.dev/api/v1/auth/resend-verification \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/auth/resend-verification", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Swap the single-use code issued by a GitHub or Google OAuth callback for tokens. The access token is returned in the body and the refresh token is set as an HttpOnly cookie. Used by the OAuth callback page after the provider redirect; an invalid or expired code returns 401. The body also says where the sign-in started: `return_to` and `tool_ids` as given to the OAuth start route (each left out when empty, and always left out when linking a provider from settings), and `new_user`, true when this sign-in created the account.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | Single-use code from the OAuth callback redirect |
Response
{
"access_token": "eyJhbGciOiJI...",
"return_to": "/blog/some-post",
"tool_ids": [611, 407],
"new_user": true
}curl -X POST https://stackness.dev/api/v1/auth/oauth/exchange \
-H "Content-Type: application/json" \
-d '{"code": "a1b2c3d4..."}'const res = await fetch("https://stackness.dev/api/v1/auth/oauth/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const data = await res.json();API keys
3Create and manage API keys for programmatic access and MCP server authentication.
Create an API key for programmatic access. The key (prefixed with "sfk_") is only returned once in the response.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Descriptive name for the key |
Response
{
"id": 1,
"name": "Claude Desktop",
"key": "sfk_abc123...",
"created_at": "2025-01-15T10:30:00Z"
}curl -X POST https://stackness.dev/api/v1/api-keys \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Claude Desktop"}'const res = await fetch("https://stackness.dev/api/v1/api-keys", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Claude Desktop" }),
});
const data = await res.json();List all API keys for the authenticated user. Key values are not included for security.
Response
{
"api_keys": [
{
"id": 1,
"name": "Claude Desktop",
"prefix": "sfk_abc1",
"created_at": "2025-01-15T10:30:00Z",
"last_used_at": "2025-01-16T08:00:00Z"
}
]
}curl https://stackness.dev/api/v1/api-keys \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/api-keys", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Permanently revoke and delete an API key. Any requests using this key will immediately fail.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | API key ID (path param) |
Response
{
"message": "api key deleted"
}curl -X DELETE https://stackness.dev/api/v1/api-keys/1 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/api-keys/1", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Users & profiles
10View and update user profiles, get followers/following lists, OG images, and QR codes.
Return the full profile of the currently authenticated user, including email and settings. The tool and move counts include every tool and move you hold, private and ended ones too.
Response
{
"id": 1,
"username": "janedoe",
"email": "jane@example.com",
"display_name": "Jane Doe",
"bio": "Full-stack developer",
"job_title": "Senior frontend engineer",
"avatar_url": "https://cdn.stackness.dev/avatars/1.jpg",
"mastodon_url": "https://mastodon.social/@janedoe",
"is_pro": false,
"onboarding_completed": true,
"created_at": "2025-01-01T00:00:00Z"
}curl https://stackness.dev/api/v1/users/me \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/users/me", {
headers: { Authorization: `Bearer ${token}` },
});
const user = await res.json();Update the authenticated user's profile fields. Only provided fields are changed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| display_name | string | No | Display name |
| bio | string | No | Short biography |
| job_title | string | No | Job title shown on the profile and blog byline |
| website | string | No | Personal website URL |
| mastodon_url | string | No | Mastodon or other fediverse profile URL (http or https). The public profile links it with rel=me, so the instance can mark the link as verified. An empty string clears it |
| location | string | No | Location |
| company | string | No | Company name |
Response
{
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe",
"bio": "Updated bio",
"website": "https://jane.dev"
}curl -X PATCH https://stackness.dev/api/v1/users/me \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"bio": "Updated bio", "website": "https://jane.dev"}'const res = await fetch("https://stackness.dev/api/v1/users/me", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ bio: "Updated bio" }),
});
const user = await res.json();Record that the authenticated user has finished or skipped the onboarding walkthrough. Sets the completion timestamp on the first call and leaves it untouched afterwards, so retries are safe. The flag is returned as onboarding_completed on GET /api/v1/users/me and on the user object embedded in auth responses.
Response
204 No Contentcurl -X POST https://stackness.dev/api/v1/users/me/onboarding/complete \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/users/me/onboarding/complete", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});Retrieve a user's public profile by username. A public profile needs no authentication. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual. The tool and move counts are what a visitor sees: the user's public tools and moves that have not ended, and none at all for a banned account. The owner's own GET /api/v1/users/me counts everything they hold.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
Response
{
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe",
"bio": "Full-stack developer",
"job_title": "Senior frontend engineer",
"avatar_url": "https://cdn.stackness.dev/avatars/1.jpg",
"mastodon_url": "https://mastodon.social/@janedoe",
"followers_count": 42,
"following_count": 18,
"stack_count": 12,
"is_pro": true
}curl https://stackness.dev/api/v1/users/janedoeconst res = await fetch("https://stackness.dev/api/v1/users/janedoe");
const profile = await res.json();List the followers of a given user. Supports cursor-based pagination. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual. An unauthenticated caller never sees a follower whose profile_visibility is logged_in: those rows are left out, and hidden_count says how many of the whole list that was, the same on every page. Any signed-in viewer, including through an API key, sees every follower, and hidden_count is omitted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 100) |
Response
{
"users": [
{
"id": 2,
"username": "johndoe",
"display_name": "John Doe",
"avatar_url": "https://cdn.stackness.dev/avatars/2.jpg",
"is_pro": false
}
],
"next_cursor": 41,
"has_more": true,
"hidden_count": 1
}curl "https://stackness.dev/api/v1/users/janedoe/followers?limit=20"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/followers?limit=20"
);
const data = await res.json();List the users that a given user is following. Supports cursor-based pagination. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual. An unauthenticated caller never sees a followed user whose profile_visibility is logged_in: those rows are left out, and hidden_count says how many of the whole list that was, the same on every page. Any signed-in viewer, including through an API key, sees every followed user, and hidden_count is omitted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 100) |
Response
{
"users": [
{
"id": 3,
"username": "alice",
"display_name": "Alice",
"avatar_url": "https://cdn.stackness.dev/avatars/3.jpg",
"is_pro": false
}
],
"has_more": false,
"hidden_count": 2
}curl "https://stackness.dev/api/v1/users/janedoe/following?limit=20"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/following?limit=20"
);
const data = await res.json();Redirects (302) to the profile's Open Graph card at /stck/{username}/opengraph-image/card, a 1200x630 PNG. Returns 404 for an unknown member. A profile set to logged_in draws the site card rather than the member, so this address never exposes a restricted profile.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
Response
302 Found, Location: https://stackness.dev/stck/janedoe/opengraph-image/cardcurl -L -o og.png https://stackness.dev/api/v1/users/janedoe/og-imageconst res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/og-image"
);
const blob = await res.blob();Generate a QR code that links to the user's profile. Returns a PNG image. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
Response
Binary PNG image (Content-Type: image/png)curl -o qr.png https://stackness.dev/api/v1/users/janedoe/qr-codeconst res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/qr-code"
);
const blob = await res.blob();Render the user's newest public move as a self-contained 480x200 SVG badge for READMEs and portfolios. Pin a specific public move with ?move={slug}; a slug that is not one of the user's public moves returns 404. A user with no public moves gets a 200 card that reads "No public moves yet". Cached for five minutes. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
| move | string | No | Slug of one of the user's public moves to pin (query param). Defaults to the newest public move |
| theme | string | No | Colour family: dark (default) or light (query param) |
Response
SVG image (Content-Type: image/svg+xml)curl -o move-card.svg "https://stackness.dev/api/v1/users/janedoe/move-card?theme=light"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/move-card?move=cut-cold-starts"
);
const svg = await res.text();Render the user's profile as a self-contained 400x216 SVG badge for READMEs and portfolios, carrying the avatar, the follower count, the public tool and move counts (public and not ended, the numbers GET /api/v1/users/{username} returns), and a QR code to the profile. Cached for five minutes. A profile whose profile_visibility is logged_in returns 404, as do a deactivated profile, a banned profile and an unknown username.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
| theme | string | No | Colour family: dark (default) or light (query param) |
Response
SVG image (Content-Type: image/svg+xml)curl -o profile-card.svg "https://stackness.dev/api/v1/users/janedoe/profile-card?theme=light"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/profile-card"
);
const svg = await res.text();Follow system
4Follow and unfollow users, and check follow status.
Start following the specified user. You will see their activity in your feed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username to follow (path param) |
Response
{
"message": "followed",
"following": true
}curl -X POST https://stackness.dev/api/v1/users/janedoe/follow \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/users/janedoe/follow", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});Stop following the specified user. Their activity will no longer appear in your feed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username to unfollow (path param) |
Response
{
"message": "unfollowed",
"following": false
}curl -X DELETE https://stackness.dev/api/v1/users/janedoe/follow \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/users/janedoe/follow", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Check whether the authenticated user is following the specified user.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username to check (path param) |
Response
{
"is_following": true
}curl https://stackness.dev/api/v1/users/janedoe/is-following \
-H "Authorization: Bearer <token>"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/is-following",
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = await res.json();Check in one request whether the authenticated user follows each of up to 50 distinct usernames. Duplicates are collapsed, and unknown or deactivated usernames come back false. An empty list returns 400 INVALID_REQUEST; more than 50 distinct usernames returns 400 TOO_MANY_USERNAMES.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| usernames | string | Yes | Comma-separated usernames to check, at most 50 distinct (query param) |
Response
{
"following": {
"alex_rust": true,
"sarah_chen": false
}
}curl "https://stackness.dev/api/v1/users/me/following-status?usernames=alex_rust,sarah_chen" \
-H "Authorization: Bearer <token>"const res = await fetch(
"https://stackness.dev/api/v1/users/me/following-status?usernames=alex_rust,sarah_chen",
{ headers: { Authorization: `Bearer ${token}` } }
);
const { following } = await res.json();Tools & categories
15Browse the tool database, search for tools, view usage stats, and submit new tools. Wherever a tool object carries `logo_url`, it can also carry `logo_dark_url`: a light variant of a logo that would vanish on a dark background, meant for dark themes. It is omitted when the logo reads fine on dark as it is.
Get the full list of tool categories (e.g., Databases, Frontend Frameworks, DevOps).
Response
{
"categories": [
{
"id": 1,
"name": "Databases",
"slug": "databases",
"display_order": 1,
"description": "Where developers keep state...",
"tool_count": 24
}
]
}curl https://stackness.dev/api/v1/categoriesconst res = await fetch("https://stackness.dev/api/v1/categories");
const data = await res.json();Everything behind one category hub page: the category with its intro, the tools ranked by user count (the top ten carry twelve weeks of trend points, each counting the public stack entries current at that week's end), the risers and fallers of the last thirty days, the tools most often paired from outside the category, the most popular moves, the slugs whose imported history the page charts, and up to six eligible comparisons between the category's own tools. Risers and fallers are omitted when fewer than three tools qualify. Every count reads public stack entries by owners who are neither banned nor deactivated, and private, friends-only and subscriber-only entries never count: user counts, pairings and the stack entry count behind `indexable` take the entries that have not ended, risers count public additions in the window and fallers public entries that ended in it, each over the current user count. `indexable` is false for a thin category, which renders a short page and stays out of the sitemap. The whole aggregate is cached for an hour. Reads an optional Authorization header: an unauthenticated caller never gets a move whose author has set profile_visibility to logged_in, checked against the author's visibility at the moment of the request rather than when the aggregate was cached, while every count still includes them. Any signed-in viewer, including through an API key, gets every move.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Category slug (path param), e.g. terminal-shell |
Response
{
"category": {
"id": 2,
"name": "Terminal & Shell",
"slug": "terminal-shell",
"display_order": 2,
"description": "Where developers keep state...",
"tool_count": 24
},
"indexable": true,
"data_as_of": "2026-09-08T09:14:02Z",
"tools": [
{
"id": 12,
"name": "Ghostty",
"slug": "ghostty",
"description": "GPU-accelerated terminal emulator",
"logo_url": "https://cdn.stackness.dev/logos/ghostty.png",
"user_count": 184,
"categories": [{ "id": 2, "name": "Terminal & Shell", "slug": "terminal-shell", "display_order": 2 }],
"created_at": "2025-04-02T10:00:00Z",
"trend": [{ "week": "2026-06-22", "user_count": 151 }]
}
],
"risers": [
{ "tool_id": 12, "name": "Ghostty", "slug": "ghostty", "count": 33, "total_users": 184, "rate": 17.9 }
],
"fallers": [],
"pairings": [
{ "tool_id": 45, "name": "Neovim", "slug": "neovim", "count": 6, "user_count": 402 }
],
"moves": [
{
"id": 88,
"slug": "ghostty-quick-terminal-hotkey",
"title": "Ghostty quick terminal on a hotkey",
"tools": [{ "id": 12, "name": "Ghostty", "slug": "ghostty" }],
"is_backdated": false,
"author": { "id": 3, "username": "sarah_chen", "is_pro": true },
"reaction_count": 12,
"comment_count": 4,
"created_at": "2026-05-11T08:00:00Z",
"updated_at": "2026-05-11T08:00:00Z"
}
],
"history_slugs": ["ghostty", "zsh", "fish"],
"comparisons": [
{
"slug": "fish-vs-zsh",
"tool_a": { "name": "Fish", "slug": "fish" },
"tool_b": { "name": "Zsh", "slug": "zsh" }
}
]
}curl https://stackness.dev/api/v1/categories/terminal-shellconst res = await fetch(
"https://stackness.dev/api/v1/categories/terminal-shell"
);
const data = await res.json();List tools with optional filtering by category. Supports cursor-based pagination. Each tool's `user_count`, and the popularity order, counts its public stack entries that have not ended, by owners who are neither banned nor deactivated; private, friends-only and subscriber-only entries never count.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| category | string | No | Filter by category slug |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 100) |
Response
{
"tools": [
{
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql",
"description": "Open-source relational database",
"website": "https://postgresql.org",
"category": "Databases",
"user_count": 340
}
],
"next_cursor": "abc123",
"has_more": true
}curl "https://stackness.dev/api/v1/tools?category=databases&limit=20"const res = await fetch(
"https://stackness.dev/api/v1/tools?category=databases&limit=20"
);
const data = await res.json();Full-text search across tool names and descriptions.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| q | string | Yes | Search query |
| limit | integer | No | Max results (default 20, max 100) |
Response
{
"tools": [
{
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql",
"description": "Open-source relational database",
"user_count": 340
}
]
}curl "https://stackness.dev/api/v1/tools/search?q=postgres"const res = await fetch(
"https://stackness.dev/api/v1/tools/search?q=postgres"
);
const data = await res.json();Get full details for a specific tool including description, website, category, and usage stats. `user_count` counts the tool's public stack entries that have not ended, by owners who are neither banned nor deactivated: the members GET /api/v1/tools/{slug}/users lists. `has_real_members` is false when none of those entries belongs to a real member, either because there are none or because every one belongs to a generated test account; a member whose profile is visible to signed-in visitors only still counts as real. `logo_dark_url` is present only for a logo that needs a dark-theme variant. `comparisons` lists the /compare/{a}-vs-{b} pages the tool appears on - the pairs someone picked by hand that also clear the per-tool data floors. It is empty while the comparison tree is switched out of search indexes. `about` is a short text about the tool in our own words, written only from the sourced facts (paragraphs separated by a blank line); it is absent until the nightly enrichment has written one. `facts` lists sourced facts as `{key, value, date?, source_url}` rows, where `source_url` is the page the value was read from and `key` is one of `developer`, `license`, `language`, `platforms`, `first_release` (an ISO date at day, month or year precision), `latest_release` (the version, with its `date` when known), `github_stars` (an integer), `repository_since` (an ISO date), `archived` (present only for an archived repository), `repository`, `docs`, `pricing` or `download` (URLs); it is an empty array for a tool nothing was found for. `facts_checked_at` is when the facts were last fetched, absent before the first run. `indexable` says whether the page clears the content floor: when false the page still serves, with `noindex, follow`, and is left out of the sitemap.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Tool slug (path param) |
Response
{
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql",
"description": "Open-source relational database",
"website": "https://postgresql.org",
"category": {
"id": 1,
"name": "Databases",
"slug": "databases"
},
"user_count": 340,
"has_real_members": true,
"created_at": "2025-01-01T00:00:00Z",
"comparisons": [
{
"pair": "mysql-vs-postgresql",
"tool": {
"id": 12,
"name": "MySQL",
"slug": "mysql",
"logo_url": "https://cdn.stackness.dev/logos/mysql.png",
"logo_dark_url": "https://cdn.stackness.dev/logos/mysql-dark.png"
}
}
],
"about": "PostgreSQL is an open-source relational database developed by the PostgreSQL Global Development Group. Teams use it as the main store for web applications, analytics and geospatial data.",
"facts": [
{
"key": "developer",
"value": "PostgreSQL Global Development Group",
"source_url": "https://www.wikidata.org/wiki/Q192490"
},
{
"key": "license",
"value": "PostgreSQL License",
"source_url": "https://www.wikidata.org/wiki/Q192490"
},
{
"key": "latest_release",
"value": "18.0",
"date": "2025-09-25",
"source_url": "https://github.com/postgres/postgres"
},
{
"key": "github_stars",
"value": "18644",
"source_url": "https://github.com/postgres/postgres"
},
{
"key": "docs",
"value": "https://www.postgresql.org/docs/",
"source_url": "https://postgresql.org"
}
],
"facts_checked_at": "2026-09-29T09:14:00Z",
"indexable": true
}curl https://stackness.dev/api/v1/tools/postgresqlconst res = await fetch("https://stackness.dev/api/v1/tools/postgresql");
const tool = await res.json();List the members who list this tool publicly today: public stack entries that have not ended, by owners who are neither banned nor deactivated. Supports cursor-based pagination. Reads an optional Authorization header. Any signed-in viewer, including through an API key, gets the list that agrees with the tool's `user_count`. An unauthenticated caller never sees a member whose profile_visibility is logged_in: those rows are left out, `user_count` still counts them, and hidden_count says how many of the whole list that was, the same on every page. hidden_count is omitted when it is 0.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Tool slug (path param) |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 100) |
Response
{
"users": [
{
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe",
"avatar_url": "https://cdn.stackness.dev/avatars/1.jpg",
"is_pro": false
}
],
"next_cursor": 812,
"has_more": true,
"hidden_count": 2
}curl "https://stackness.dev/api/v1/tools/postgresql/users?limit=10"const res = await fetch(
"https://stackness.dev/api/v1/tools/postgresql/users?limit=10"
);
const data = await res.json();Return the approved tools among the given ids, in the order asked, in the same tile shape as GET /api/v1/tools/suggestions. Unknown and unapproved ids are left out. Used to show the tools a reader picked before signing up.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| ids | string | Yes | Comma-separated positive tool ids (1-50). A missing, malformed or longer list returns 400 |
Response
{
"tools": [
{
"tool_id": 611,
"name": "Socket",
"slug": "socket",
"logo_url": "https://cdn.stackness.dev/logos/socket.png",
"description": "Supply chain security for open source dependencies",
"category": { "id": 9, "name": "Security", "slug": "security" },
"reason": "popular",
"score": 0
}
]
}curl "https://stackness.dev/api/v1/tools/lookup?ids=611,407"const res = await fetch("https://stackness.dev/api/v1/tools/lookup?ids=611,407");
const data = await res.json();Power a tool picker: given the slugs a user has already selected, returns tools commonly used alongside them, scored by how many of the selected tools each candidate is paired with. With no selection it returns trending tools. The list is topped up with popular tools from the selection's categories so it is never empty, and excludes the selected tools plus, when authenticated, everything already in your stack.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| selected | string | No | Comma-separated tool slugs already selected (max 50) |
| category | string | No | Category slug to prefer when topping up with popular tools |
| limit | integer | No | Max results (default 24, max 60) |
Response
{
"tools": [
{
"tool_id": 5,
"name": "Redis",
"slug": "redis",
"logo_url": "https://cdn.stackness.dev/logos/redis.png",
"description": "In-memory data store",
"category": { "id": 2, "name": "Databases", "slug": "databases" },
"reason": "used_with:postgresql",
"score": 180
}
]
}curl "https://stackness.dev/api/v1/tools/suggestions?selected=postgresql,go&limit=24"const res = await fetch(
"https://stackness.dev/api/v1/tools/suggestions?selected=postgresql,go&limit=24"
);
const data = await res.json();Get tools commonly used alongside this one, based on how many members list both in their public stacks today (public entries that have not ended, by owners who are neither banned nor deactivated). Each peer carries its categories so a caller can tell which of them sit in the same category as the tool being viewed. Co-usage says nothing about which pairs have a comparison page: those are picked by hand and listed in `comparisons` on GET /api/v1/tools/{slug}.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Tool slug (path param) |
| limit | integer | No | Max results (default 10) |
Response
{
"tools": [
{
"tool_id": 5,
"name": "Redis",
"slug": "redis",
"logo_url": "https://cdn.stackness.dev/logos/redis.png",
"user_count": 180,
"categories": [
{ "id": 3, "name": "Databases", "slug": "databases" }
]
}
]
}curl "https://stackness.dev/api/v1/tools/postgresql/also-used-with"const res = await fetch(
"https://stackness.dev/api/v1/tools/postgresql/also-used-with"
);
const data = await res.json();Get the adoption trend for a tool over time: one point per week, counting the public stack entries current at that week's end (added by then and not ended by then). The last point equals the tool's `user_count`.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Tool slug (path param) |
Response
{
"tool": "postgresql",
"data_points": [
{ "date": "2025-01-06", "user_count": 300 },
{ "date": "2025-01-13", "user_count": 320 },
{ "date": "2025-01-20", "user_count": 340 }
]
}curl https://stackness.dev/api/v1/tools/postgresql/trendconst res = await fetch(
"https://stackness.dev/api/v1/tools/postgresql/trend"
);
const data = await res.json();Submit a tool to be added to the database. Submitted tools are reviewed before becoming public.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Tool name |
| website_url | string | No | Tool website URL |
| description | string | No | Short description |
| logo_url | string | No | Logo image URL |
| category_ids | integer[] | No | Category IDs the tool belongs to |
Response
{
"id": 500,
"name": "My New Tool",
"slug": "my-new-tool",
"status": "pending",
"created_at": "2025-01-20T10:00:00Z"
}curl -X POST https://stackness.dev/api/v1/tools \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "My New Tool", "website_url": "https://example.com"}'const res = await fetch("https://stackness.dev/api/v1/tools", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "My New Tool",
website_url: "https://example.com",
}),
});
const data = await res.json();Get the activity feed for a specific tool (moves, stack additions, etc.). Only events of public stack entries appear, ended ones included since a removal is an event too; an entry made private later takes its events with it. Reads an optional Authorization header: an unauthenticated caller never sees the events of a member whose profile_visibility is logged_in, and any signed-in viewer, including through an API key, sees them as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Tool slug (path param) |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 50) |
Response
{
"items": [
{
"id": "feed_123",
"type": "stack_add",
"user": { "username": "janedoe", "display_name": "Jane Doe" },
"tool": { "name": "PostgreSQL", "slug": "postgresql" },
"created_at": "2025-01-20T10:00:00Z"
}
],
"next_cursor": "abc123",
"has_more": true
}curl "https://stackness.dev/api/v1/tools/postgresql/feed?limit=10"const res = await fetch(
"https://stackness.dev/api/v1/tools/postgresql/feed?limit=10"
);
const data = await res.json();Get the imported popularity history for a tool as labelled time series, one per source, metric, and granularity combination. Values come from external sources in each source's own unit, so series are displayed separately and never summed. Each point's bucket is the start date of the month or year it covers. A tool with no imported history returns an empty series list; an unknown source or metric returns 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Tool slug (path param) |
| source | string | No | Comma-separated source filter: so_survey, sede_tags, wiki_pageviews, wikidata, npm, hn_mentions, homebrew, gharchive, survey_js, survey_jetbrains, redmonk, google_trends, pypi, wayback, manual (query param) |
| metric | string | No | Comma-separated metric filter: usage_share_pct, satisfaction_pct, question_count, pageviews, downloads, mentions, installs, stars, rank, search_interest (query param) |
Response
{
"series": [
{
"source": "so_survey",
"source_label": "Stack Overflow developer survey",
"source_url": "https://survey.stackoverflow.co/2024",
"metric": "usage_share_pct",
"metric_label": "Usage share",
"unit": "% of respondents",
"granularity": "year",
"confidence": "high",
"origin": "imported",
"points": [
{ "bucket": "2023-01-01", "value": 45.6, "is_estimated": false },
{ "bucket": "2024-01-01", "value": 48.7, "is_estimated": false }
]
}
]
}curl "https://stackness.dev/api/v1/tools/postgresql/history?source=so_survey&metric=usage_share_pct"const res = await fetch(
"https://stackness.dev/api/v1/tools/postgresql/history?source=so_survey"
);
const data = await res.json();One row per external dataset that has imported history stored, with its oldest and newest bucket, how many datapoints and how many tools or moves it covers, and whether it refreshes automatically or waits for a manual export. It is what tells you a dataset has stopped receiving data: a source whose newest bucket is months old is frozen, not empty. Buckets are the first day of the month or year they cover. Sources come back in alphabetical order.
Response
{
"sources": [
{
"source": "npm",
"source_label": "npm downloads",
"refresh": "automatic",
"oldest_bucket": "2015-01-01",
"newest_bucket": "2026-08-01",
"row_count": 48210,
"target_count": 312
},
{
"source": "sede_tags",
"source_label": "Stack Exchange data explorer",
"refresh": "manual",
"oldest_bucket": "2008-09-01",
"newest_bucket": "2026-08-01",
"row_count": 20251,
"target_count": 244
}
],
"checked_at": "2026-09-11T08:00:00Z"
}curl "https://stackness.dev/api/v1/history/sources"const res = await fetch("https://stackness.dev/api/v1/history/sources");
const data = await res.json();Everything behind a /compare/{a}-vs-{b} page: both tools with their user counts and twelve weeks of trend points (counted as on GET /api/v1/tools/{slug} and its trend) and the net change, their imported history series, their three most popular public moves, how many members list both, how many moved from one to the other in each direction and the months those moves span, the pairings they share and the ones only each has, and the curated verdict when one is published. The slugs may come in either order; `pair`, `tool_a` and `tool_b` always carry the canonical alphabetical order, so a caller can redirect to it. A page exists only for a pair someone picked by hand, and only once both of its tools clear the data floors (a shared category, and a minimum number of members and imported history series each). Shared users, switch counts and pairings are reported as facts about the pair; they never decide whether it has a page. A shared user lists both tools in public entries that have not ended; a switch needs two public entries, the one left behind ended. An unknown or unapproved tool returns 404 with code `NOT_FOUND`; a pair with no page returns 404 with code `NOT_ELIGIBLE`. `noindex` is true while the comparison tree is switched out of search indexes, and for any pair without a published verdict: the page still serves, but only a pair with hand-written framing is indexable or listed in the sitemap feed. Reads an optional Authorization header: an unauthenticated caller never gets a move whose author has set profile_visibility to logged_in, and when the verdict's author has, the verdict is returned without its author. Any signed-in viewer, including through an API key, gets both as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| a | string | Yes | First tool slug (path param), e.g. vs-code |
| b | string | Yes | Second tool slug (path param), e.g. neovim |
Response
{
"pair": "neovim-vs-vs-code",
"tool_a": {
"id": 45,
"name": "Neovim",
"slug": "neovim",
"description": "Hyperextensible Vim-based text editor",
"website_url": "https://neovim.io",
"logo_url": "https://cdn.stackness.dev/logos/neovim.png",
"is_approved": true,
"user_count": 402,
"categories": [{ "id": 1, "name": "Editor & IDE", "slug": "editor-ide", "display_order": 1 }],
"created_at": "2025-01-10T09:00:00Z",
"trend": [
{ "week": "2026-06-22", "user_count": 371 },
{ "week": "2026-09-07", "user_count": 402 }
],
"trend_delta": 31,
"history": [
{
"source": "so_survey",
"source_label": "Stack Overflow developer survey",
"source_url": "https://survey.stackoverflow.co",
"metric": "usage_share_pct",
"metric_label": "Usage share",
"unit": "% of respondents",
"granularity": "year",
"confidence": "high",
"origin": "imported",
"points": [
{ "bucket": "2024-01-01", "value": 12.5, "is_estimated": false },
{ "bucket": "2025-01-01", "value": 14.1, "is_estimated": false }
]
}
],
"moves": [
{
"id": 88,
"slug": "moved-my-config-to-lazy-nvim",
"title": "Moved my config to lazy.nvim",
"tools": [{ "id": 45, "name": "Neovim", "slug": "neovim" }],
"is_backdated": false,
"author": { "id": 3, "username": "sarah_chen", "is_pro": true },
"reaction_count": 12,
"comment_count": 4,
"created_at": "2026-05-11T08:00:00Z",
"updated_at": "2026-05-11T08:00:00Z"
}
]
},
"tool_b": {
"id": 12,
"name": "VS Code",
"slug": "vs-code",
"is_approved": true,
"user_count": 1204,
"categories": [{ "id": 1, "name": "Editor & IDE", "slug": "editor-ide", "display_order": 1 }],
"created_at": "2024-11-02T09:00:00Z",
"trend": [
{ "week": "2026-06-22", "user_count": 1188 },
{ "week": "2026-09-07", "user_count": 1204 }
],
"trend_delta": 16,
"history": [],
"moves": []
},
"shared_users": 96,
"switched_a_to_b": 7,
"switched_b_to_a": 23,
"switch_period": { "from": "2023-02", "to": "2026-08" },
"pairings": {
"shared": [
{ "tool_id": 5, "name": "Git", "slug": "git", "users_with_a": 210, "users_with_b": 640 }
],
"only_a": [
{ "tool_id": 61, "name": "tmux", "slug": "tmux", "users_with_a": 118, "users_with_b": 0 }
],
"only_b": [
{ "tool_id": 77, "name": "GitHub Copilot", "slug": "github-copilot", "users_with_a": 0, "users_with_b": 301 }
]
},
"data_as_of": "2026-09-07T18:02:11Z",
"verdict": {
"body": "Neovim suits people who already live in the terminal...",
"author": { "username": "gordeychuk_s", "display_name": "Sergei Gordeichuk" },
"updated_at": "2026-09-05T10:00:00Z"
},
"noindex": false
}curl https://stackness.dev/api/v1/compare/vs-code/neovimconst res = await fetch(
"https://stackness.dev/api/v1/compare/vs-code/neovim"
);
if (res.status === 404) {
const { code } = await res.json(); // "NOT_FOUND" or "NOT_ELIGIBLE"
}
const data = await res.json();Stack management
23Add, update and remove the tools in your stack, and read public stacks and their history.
Add a tool to your personal stack with an optional note about how you use it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| tool_id | integer | Yes | ID of the tool to add |
| personal_note | string | No | Personal note about why/how you use this tool |
| category_id | integer | No | Category ID (see GET /categories); omitted means Uncategorized |
| start_date | string | No | Month you started using the tool, RFC3339. Month granularity: the day is ignored and the date is stored as the first of the month |
| end_date | string | No | Month you stopped using the tool, RFC3339. Month granularity: the day is ignored |
| visibility | string | No | "public" (default), "friends", "private", or "subscribers" |
| screenshot_url | string | No | Screenshot URL for this entry |
Response
{
"id": 1,
"tool": {
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql"
},
"personal_note": "Primary database for all projects",
"visibility": "public",
"created_at": "2025-01-20T10:00:00Z"
}curl -X POST https://stackness.dev/api/v1/stack/tools \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"tool_id": 1, "personal_note": "Primary database"}'const res = await fetch("https://stackness.dev/api/v1/stack/tools", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tool_id: 1, personal_note: "Primary database" }),
});
const data = await res.json();Add up to 50 tools in one transaction. Tools already in your stack are skipped and reported instead of failing the call. Public adds publish a single grouped stack update so followers see one card, not one per tool, unless announce is false. Each entry defaults to the tool's first catalog category unless category_id is given.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| tools | array | Yes | 1-50 entries of { tool_id: integer, category_id?: integer }. Duplicate tool_ids are rejected |
| visibility | string | No | public (default), friends, or subscribers (subscribers requires a Supporter subscription). Applies to every entry |
| note | string | No | Optional note shown on the grouped stack update (max 2000 chars) |
| announce | boolean | No | true by default. Set false to create the entries without publishing anything to the feed, then announce them later with POST /api/v1/stack/updates. The response omits stack_update_id |
| source | string | No | Where the add came from, for analytics only: stack_builder (default), preselected, blog or tool_page. Any other value counts as stack_builder |
Response
{
"added": [
{
"id": 12,
"tool": { "id": 1, "name": "PostgreSQL", "slug": "postgresql" },
"visibility": "public",
"created_at": "2025-01-20T10:00:00Z"
}
],
"skipped": [{ "tool_id": 5, "reason": "already_in_stack" }],
"stack_update_id": 42
}curl -X POST https://stackness.dev/api/v1/stack/tools/bulk \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"tools": [{"tool_id": 1}, {"tool_id": 5}], "note": "My starter kit"}'const res = await fetch("https://stackness.dev/api/v1/stack/tools/bulk", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tools: [{ tool_id: 1 }, { tool_id: 5 }], note: "My starter kit" }),
});
const data = await res.json();List all tools in the authenticated user's stack, grouped by category.
Response
{
"tools": [
{
"id": 1,
"tool": {
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql",
"category": "Databases"
},
"note": "Primary database",
"added_at": "2025-01-20T10:00:00Z"
}
],
"total": 12
}curl https://stackness.dev/api/v1/stack/tools \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/stack/tools", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update the note, category, dates, or visibility of a tool in your stack. Another user's public entry answers 403; another user's entry that is not public answers the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Stack entry ID (path param) |
| personal_note | string | No | Updated note |
| category_id | integer | No | Category ID (see GET /categories) |
| start_date | string | No | Month you started using the tool, RFC3339. Month granularity: the day is ignored and the date is stored as the first of the month |
| end_date | string | No | Month you stopped using the tool, RFC3339. Month granularity: the day is ignored |
| visibility | string | No | "public", "friends", "private", or "subscribers" |
| screenshot_url | string | No | Screenshot URL for this entry |
Response
{
"id": 1,
"tool": {
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql"
},
"personal_note": "Updated note",
"visibility": "public",
"updated_at": "2025-01-20T10:00:00Z"
}curl -X PATCH https://stackness.dev/api/v1/stack/tools/1 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"personal_note": "Updated note"}'const res = await fetch("https://stackness.dev/api/v1/stack/tools/1", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ personal_note: "Updated note" }),
});
const data = await res.json();Remove a tool from your stack. This does not delete the tool from the database. Another user's public entry answers 403; another user's entry that is not public answers the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Stack entry ID (path param) |
Response
{
"message": "tool removed from stack"
}curl -X DELETE https://stackness.dev/api/v1/stack/tools/1 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/stack/tools/1", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});View the public stack for any user by their username. The response carries the subspace grid (user-defined groups with ordered tools, moves, and clusters plus per-tile layout state) alongside the legacy category grouping. Tracks, the optional level above subspaces, come in their own list; each subspace names its track in track_id, or null when it sits outside every track. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
Response
{
"categories": [
{
"category": { "id": 2, "name": "Databases", "slug": "databases", "display_order": 1 },
"tools": [{ "id": 12, "tool": { "id": 1, "name": "PostgreSQL", "slug": "postgresql" } }]
}
],
"tracks": [{ "id": 3, "name": "Backend", "slug": "backend", "position": 0 }],
"subspaces": [
{
"id": 4,
"name": "Daily drivers",
"slug": "daily-drivers",
"color": "indigo",
"position": 0,
"start_collapsed": false,
"width": "full",
"track_id": 3,
"items": [
{
"type": "tool",
"position": 0,
"size": "featured",
"use_image_background": false,
"dim_image_background": false,
"user_tool": {
"id": 12,
"tool": { "id": 1, "name": "PostgreSQL", "slug": "postgresql" },
"personal_note": "Primary database",
"reactions": [{ "emoji": "fire", "count": 3, "reacted": false }],
"comment_count": 1
}
},
{
"type": "move",
"position": 1,
"size": "normal",
"use_image_background": false,
"dim_image_background": false,
"move": { "id": 7, "slug": "prompt-driven-tdd", "title": "Prompt-driven TDD", "reactions": [], "comment_count": 0 }
}
],
"clusters": [
{
"id": 2,
"name": "Little helpers",
"position": 2,
"items": [{ "type": "tool", "position": 0, "size": "mini", "cluster_id": 2, "user_tool": { "id": 15 } }]
}
]
}
],
"ungrouped": { "items": [], "clusters": [] }
}curl https://stackness.dev/api/v1/users/janedoe/stackconst res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/stack"
);
const data = await res.json();Create a named, colored subspace on your profile grid. The anchor slug is derived from the name once and stays stable across renames, so deep links keep working.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Subspace name, at most 50 characters |
| color | string | No | Palette color: indigo, sky, teal, green, amber, rose, violet, or slate. Defaults to indigo |
| start_collapsed | boolean | No | Whether visitors see this subspace collapsed at first |
Response
{
"id": 4,
"name": "Daily drivers",
"slug": "daily-drivers",
"color": "indigo",
"position": 3,
"start_collapsed": false,
"width": "full",
"track_id": null,
"created_at": "2026-08-25T12:00:00Z",
"updated_at": "2026-08-25T12:00:00Z"
}curl -X POST https://stackness.dev/api/v1/stack/subspaces \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Daily drivers", "color": "indigo"}'const res = await fetch("https://stackness.dev/api/v1/stack/subspaces", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Daily drivers", color: "indigo" }),
});Set the top-to-bottom order of your subspaces. Positions follow the order of the given ids.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| subspace_ids | number[] | Yes | All subspace ids in their new order |
Response
204 No Contentcurl -X PATCH https://stackness.dev/api/v1/stack/subspaces/reorder \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"subspace_ids": [4, 2, 7]}'await fetch("https://stackness.dev/api/v1/stack/subspaces/reorder", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ subspace_ids: [4, 2, 7] }),
});Rename or recolor a subspace, set whether it starts collapsed, set its panel width, or move it into or out of a track. The anchor slug never changes on rename.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | number | Yes | Subspace ID (path param) |
| name | string | No | New name, at most 50 characters |
| color | string | No | New palette color |
| start_collapsed | boolean | No | Whether visitors see this subspace collapsed at first |
| width | string | No | Panel width on desktop: full or half. Two half-width subspaces sit side by side; on mobile they stack |
| track_id | number | null | No | Move the subspace into one of your tracks. Send null to move it out of its track; leave the field out to keep it where it is |
Response
{
"id": 4,
"name": "Weekend experiments",
"slug": "daily-drivers",
"color": "rose",
"position": 3,
"start_collapsed": false,
"width": "half",
"track_id": 3,
"created_at": "2026-08-25T12:00:00Z",
"updated_at": "2026-08-25T12:30:00Z"
}curl -X PATCH https://stackness.dev/api/v1/stack/subspaces/4 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"color": "rose"}'await fetch("https://stackness.dev/api/v1/stack/subspaces/4", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ color: "rose" }),
});Delete a subspace. Its tiles and clusters are not removed - they fall back to the ungrouped grid at the end of the profile.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | number | Yes | Subspace ID (path param) |
Response
204 No Contentcurl -X DELETE https://stackness.dev/api/v1/stack/subspaces/4 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/stack/subspaces/4", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});List your tracks in display order. A track is a named role or area of work, such as Backend or Mobile, that groups subspaces on your profile.
Response
{
"tracks": [
{
"id": 3,
"name": "Backend",
"slug": "backend",
"position": 0,
"created_at": "2026-09-28T12:00:00Z",
"updated_at": "2026-09-28T12:00:00Z"
}
]
}curl https://stackness.dev/api/v1/stack/tracks \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/stack/tracks", {
headers: { Authorization: `Bearer ${token}` },
});
const { tracks } = await res.json();Create a track after your others and, optionally, move subspaces into it in the same call. The slug behind ?track= links is derived from the name once and stays stable across renames. A stack holds at most 12 tracks.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Track name, at most 40 characters |
| subspace_ids | number[] | No | Your subspaces to move into the new track |
Response
{
"id": 5,
"name": "Mobile",
"slug": "mobile",
"position": 1,
"created_at": "2026-09-28T12:00:00Z",
"updated_at": "2026-09-28T12:00:00Z"
}curl -X POST https://stackness.dev/api/v1/stack/tracks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Mobile", "subspace_ids": [6, 7]}'const res = await fetch("https://stackness.dev/api/v1/stack/tracks", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Mobile", subspace_ids: [6, 7] }),
});Set the order of your tracks. Positions follow the order of the given ids.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| track_ids | number[] | Yes | All track ids in their new order |
Response
204 No Contentcurl -X PATCH https://stackness.dev/api/v1/stack/tracks/reorder \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"track_ids": [5, 3]}'await fetch("https://stackness.dev/api/v1/stack/tracks/reorder", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ track_ids: [5, 3] }),
});Rename a track. The slug never changes, so existing ?track= links keep working.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | number | Yes | Track ID (path param) |
| name | string | Yes | New name, at most 40 characters |
Response
{
"id": 5,
"name": "Apps",
"slug": "mobile",
"position": 1,
"created_at": "2026-09-28T12:00:00Z",
"updated_at": "2026-09-28T12:30:00Z"
}curl -X PATCH https://stackness.dev/api/v1/stack/tracks/5 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Apps"}'await fetch("https://stackness.dev/api/v1/stack/tracks/5", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Apps" }),
});Delete a track. Its subspaces and everything in them stay on your profile, outside any track.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | number | Yes | Track ID (path param) |
Response
204 No Contentcurl -X DELETE https://stackness.dev/api/v1/stack/tracks/5 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/stack/tracks/5", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Create a named cluster - a single tile that holds mini chips. Assign items to it through the layout endpoint.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Cluster name, at most 50 characters |
| subspace_id | number | No | Subspace the cluster tile lives in; omit for the ungrouped grid |
| position | number | No | Grid position of the cluster tile |
Response
{
"id": 2,
"name": "Little helpers",
"subspace_id": 4,
"position": 5,
"created_at": "2026-08-25T12:00:00Z",
"updated_at": "2026-08-25T12:00:00Z"
}curl -X POST https://stackness.dev/api/v1/stack/clusters \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Little helpers", "subspace_id": 4}'const res = await fetch("https://stackness.dev/api/v1/stack/clusters", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Little helpers", subspace_id: 4 }),
});Rename a cluster.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | number | Yes | Cluster ID (path param) |
| name | string | Yes | New name, at most 50 characters |
Response
{
"id": 2,
"name": "Python helpers",
"subspace_id": 4,
"position": 5,
"created_at": "2026-08-25T12:00:00Z",
"updated_at": "2026-08-25T12:45:00Z"
}curl -X PATCH https://stackness.dev/api/v1/stack/clusters/2 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Python helpers"}'await fetch("https://stackness.dev/api/v1/stack/clusters/2", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Python helpers" }),
});Ungroup a cluster: its mini chips are promoted back to small tiles in place, then the cluster tile is removed.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | number | Yes | Cluster ID (path param) |
Response
204 No Contentcurl -X DELETE https://stackness.dev/api/v1/stack/clusters/2 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/stack/clusters/2", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Batch-save the profile grid arrangement: item positions, sizes, subspace and cluster membership, image background flags, and cluster placement in one call. Size is one of featured, wide, tall, normal, small, or mini; items inside a cluster must use size mini.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| items | object[] | Yes | Item placements: { type: 'tool' | 'move', id, subspace_id?, cluster_id?, position, size, use_image_background?, dim_image_background? } |
| clusters | object[] | No | Cluster placements: { id, subspace_id?, position } |
Response
204 No Contentcurl -X PUT https://stackness.dev/api/v1/stack/layout \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"items": [{"type": "tool", "id": 12, "subspace_id": 4, "position": 0, "size": "featured"}]}'await fetch("https://stackness.dev/api/v1/stack/layout", {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
items: [
{ type: "tool", id: 12, subspace_id: 4, position: 0, size: "featured" },
],
}),
});Publish a summary of recent changes to your stack as a feed item visible to your followers. Each change carries a change_type (tool_added, tool_removed, move_added, move_retired, subspace_created, subspace_renamed, subspace_removed, or layout_updated) and a target_type (user_tool, move, subspace, or stack). Only rows for your own public stack entries and moves are stored, together with subspace and stack rows; a row naming an entry that is not public, is not yours or no longer exists is dropped, and a publish left with no row answers 400.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| note | string | No | Optional note about what changed and why, at most 2000 characters |
| changes | array | Yes | 1-50 changes: { change_type, target_type, target_id, target_name, target_logo?, target_slug? } |
Response
{
"id": 1,
"user_id": 42,
"note": "Spring cleaning",
"changes": [
{ "change_type": "tool_added", "target_type": "user_tool", "target_id": 12, "target_name": "Bun" },
{ "change_type": "subspace_created", "target_type": "subspace", "target_id": 4, "target_name": "Daily drivers" },
{ "change_type": "layout_updated", "target_type": "stack", "target_id": 0, "target_name": "Stack layout" }
],
"published_at": "2025-01-20T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z"
}curl -X POST https://stackness.dev/api/v1/stack/updates \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"note": "Spring cleaning", "changes": [{"change_type": "tool_added", "target_type": "user_tool", "target_id": 12, "target_name": "Bun"}]}'const res = await fetch("https://stackness.dev/api/v1/stack/updates", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
note: "Spring cleaning",
changes: [
{
change_type: "tool_added",
target_type: "user_tool",
target_id: 12,
target_name: "Bun",
},
],
}),
});
const data = await res.json();Copy one item from someone else's stack into your own. `target_type` picks what is being copied: `user_tool` copies a single stack entry (the tool, its category and its note), `move` copies a move together with the tools it references. Returns 201 with the item that was created. Copying a tool you already have returns 409 with code `TOOL_ALREADY_IN_STACK` - moves have no such guard and may be copied more than once. Copying your own item returns 403, and a source that was deleted or is no longer public returns 404.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | What to copy: "user_tool" or "move" |
| target_id | integer | Yes | ID of the stack entry or move to copy, as returned by the profile and move endpoints |
Response
{
"type": "user_tool",
"user_tool": {
"id": 812,
"tool": { "id": 10, "name": "Bun", "slug": "bun" },
"category": { "id": 3, "name": "Runtime", "slug": "runtime", "display_order": 2 },
"personal_note": "Faster installs than npm",
"visibility": "public",
"is_backdated": false,
"created_at": "2026-08-28T10:00:00Z",
"updated_at": "2026-08-28T10:00:00Z",
"reactions": [],
"comment_count": 0
}
}curl -X POST https://stackness.dev/api/v1/stack/copy \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"target_type": "user_tool", "target_id": 412}'const res = await fetch("https://stackness.dev/api/v1/stack/copy", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_type: "user_tool", target_id: 412 }),
});
if (res.status === 409) {
// code: "TOOL_ALREADY_IN_STACK" - the tool is already in your stack
}
const data = await res.json();Every dated stack entry for a user - tools and moves, active and retired - with category, dates, reactions and comment counts. Powers the stack-over-time chart, so the list is not paginated. The owner (authenticated as that user) sees private entries; every other viewer sees public-visibility entries only. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
Response
{
"items": [
{
"type": "user_tool",
"id": 12,
"tool": { "id": 1, "name": "PostgreSQL", "slug": "postgresql" },
"category": { "id": 2, "name": "Databases", "slug": "databases", "display_order": 1 },
"personal_note": "Primary database",
"start_date": "2023-04-01T00:00:00Z",
"end_date": "2025-01-01T00:00:00Z",
"comment_count": 3,
"reactions": [{ "emoji": "fire", "count": 2, "reacted": false }]
},
{
"type": "move",
"id": 7,
"title": "Prompt-driven TDD",
"start_date": "2024-06-01T00:00:00Z",
"comment_count": 0,
"reactions": []
}
]
}curl "https://stackness.dev/api/v1/users/janedoe/history"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/history"
);
const data = await res.json();Get a single published stack update by id, including its note and the changes it announced. Each change names what it touched: change_type is one of tool_added, tool_removed, move_added, move_retired, subspace_created, subspace_renamed, subspace_removed, or layout_updated, and target_type is user_tool, move, subspace, or stack. Anyone but the publisher gets a user_tool or move row only while its entry still exists and is public; an update left with no row and no note answers the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Stack update id (path param) |
Response
{
"id": 42,
"user_id": 1,
"note": "Swapped my test runner and retired an old workflow.",
"changes": [
{
"change_type": "tool_added",
"target_type": "user_tool",
"target_id": 20,
"target_name": "Vitest",
"target_slug": "vitest"
},
{
"change_type": "move_retired",
"target_type": "move",
"target_id": 7,
"target_name": "Prompt-Driven TDD Workflow",
"target_slug": "prompt-driven-tdd-workflow"
}
],
"published_at": "2025-01-20T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z"
}curl https://stackness.dev/api/v1/stack/updates/42 \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/stack/updates/42", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Moves
8Create and manage moves - techniques, workflows, and approaches for how you use your stack.
Create a new move - a technique, workflow, or approach you use. Moves appear in your followers' feeds.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| title | string | Yes | Move title |
| description | string | No | Detailed description of the move |
| tool_ids | integer[] | No | IDs of tools this move references |
| category_id | integer | No | Category ID (see GET /categories) |
| start_date | string | No | Month you started using the move, RFC3339. Month granularity: the day is ignored and the date is stored as the first of the month |
| end_date | string | No | Month you stopped using the move, RFC3339. Month granularity: the day is ignored |
| cover_image_url | string | No | Cover image URL |
| visibility | string | No | "public" (default), "friends", "private", or "subscribers" |
Response
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Prompt-Driven TDD Workflow",
"description": "Write the test prompt first, then implement until green...",
"tools": [{ "name": "Vitest", "slug": "vitest" }],
"visibility": "public",
"created_at": "2025-01-20T10:00:00Z"
}curl -X POST https://stackness.dev/api/v1/moves \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"title": "Prompt-Driven TDD Workflow",
"description": "Write the test prompt first...",
"tool_ids": [20]
}'const res = await fetch("https://stackness.dev/api/v1/moves", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Prompt-Driven TDD Workflow",
tool_ids: [20],
}),
});
const data = await res.json();List all moves created by the authenticated user. Supports cursor-based pagination.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 50) |
Response
{
"moves": [
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Prompt-Driven TDD Workflow",
"visibility": "public",
"created_at": "2025-01-20T10:00:00Z"
}
],
"next_cursor": "abc123",
"has_more": false
}curl "https://stackness.dev/api/v1/moves?limit=10" \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/moves?limit=10", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Get full details for a specific move by id or slug. The slug is minted once at creation and never changes, so it is safe to store. You get your own moves in every visibility; another user's move only while it is public and its owner's account is active, and otherwise the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Move id or slug (path param). An all-digits segment is read as an id, anything else as a slug. |
Response
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Prompt-Driven TDD Workflow",
"description": "Write the test prompt first, then implement until green...",
"tools": [{ "name": "Vitest", "slug": "vitest" }],
"visibility": "public",
"created_at": "2025-01-20T10:00:00Z"
}curl https://stackness.dev/api/v1/moves/1 \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/moves/1", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update a move you created. Sending tool_ids replaces the move's tools; omit it to leave them unchanged. Another user's public move answers 403; another user's move that is not public answers the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Move id or slug (path param). An all-digits segment is read as an id, anything else as a slug. |
| title | string | No | Updated title |
| description | string | No | Updated description |
| tool_ids | integer[] | No | Replaces the move's tools; omit to leave them unchanged |
| category_id | integer | No | Category ID (see GET /categories) |
| start_date | string | No | Month you started using the move, RFC3339. Month granularity: the day is ignored and the date is stored as the first of the month |
| end_date | string | No | Month you stopped using the move, RFC3339. Month granularity: the day is ignored |
| cover_image_url | string | No | Cover image URL |
| visibility | string | No | "public", "friends", "private", or "subscribers" |
Response
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Updated title",
"description": "Updated description",
"visibility": "public"
}curl -X PATCH https://stackness.dev/api/v1/moves/1 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Updated title"}'const res = await fetch("https://stackness.dev/api/v1/moves/1", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "Updated title" }),
});
const data = await res.json();Permanently delete a move you created. Another user's public move answers 403; another user's move that is not public answers the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Move id or slug (path param). An all-digits segment is read as an id, anything else as a slug. |
Response
{
"message": "move deleted"
}curl -X DELETE https://stackness.dev/api/v1/moves/1 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/moves/1", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});List all public moves created by a user. Supports cursor-based pagination. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 50) |
Response
{
"moves": [
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Prompt-Driven TDD Workflow",
"created_at": "2025-01-20T10:00:00Z"
}
],
"next_cursor": "abc123",
"has_more": false
}curl "https://stackness.dev/api/v1/users/janedoe/moves?limit=10"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/moves?limit=10"
);
const data = await res.json();List public moves across all users with author info plus reaction and comment counts. Supports sorting, text search, category and tool filtering, and cursor-based pagination. Retired moves (those with an end date) and moves by deactivated accounts are excluded. No authentication required. Reads an optional Authorization header: an unauthenticated caller never gets a move whose author has set profile_visibility to logged_in, and any signed-in viewer, including through an API key, gets them as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| sort | string | No | Sort order: popularity (default, by reaction + comment count), recent, or title (query param) |
| q | string | No | Search text matched against move titles and descriptions, max 100 characters (query param) |
| category | string | No | Category slug to filter by (query param) |
| tool | string | No | Tool slug to filter by: only moves that use that tool are returned (query param). Combines with sort, category and limit. An unknown slug returns an empty page, not a 404. |
| cursor | string | No | Pagination cursor from the previous page's next_cursor (query param) |
| limit | integer | No | Items per page (default 20, max 100) (query param) |
Response
{
"moves": [
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Prompt-Driven TDD Workflow",
"description": "Write the test prompt first, then implement until green...",
"category": { "id": 3, "name": "AI Tools", "slug": "ai-tools", "display_order": 3 },
"tools": [{ "id": 20, "name": "Vitest", "slug": "vitest" }],
"is_backdated": false,
"author": {
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe",
"is_pro": false
},
"reaction_count": 12,
"comment_count": 4,
"created_at": "2025-01-20T10:00:00Z",
"updated_at": "2025-01-20T10:00:00Z"
}
],
"next_cursor": "16_1",
"has_more": true
}curl "https://stackness.dev/api/v1/moves/public?sort=popularity&limit=10"const res = await fetch(
"https://stackness.dev/api/v1/moves/public?sort=popularity&limit=10"
);
const data = await res.json();Get full details for a single public-visibility move by id or slug, including author info plus reaction and comment counts. A move that is not public, or whose author is deactivated, returns 404. No authentication required. Returns 404 to an unauthenticated caller when the author has set profile_visibility to logged_in, exactly as for a missing move; any signed-in viewer, including through an API key, sees the move as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | Move id or slug (path param). An all-digits segment is read as an id, anything else as a slug. |
Response
{
"id": 1,
"slug": "prompt-driven-tdd-workflow",
"title": "Prompt-Driven TDD Workflow",
"description": "Write the test prompt first, then implement until green...",
"category": { "id": 3, "name": "AI Tools", "slug": "ai-tools", "display_order": 3 },
"tools": [{ "id": 20, "name": "Vitest", "slug": "vitest" }],
"is_backdated": false,
"author": {
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe",
"is_pro": false
},
"reaction_count": 12,
"comment_count": 4,
"created_at": "2025-01-20T10:00:00Z",
"updated_at": "2025-01-20T10:00:00Z"
}curl https://stackness.dev/api/v1/moves/public/prompt-driven-tdd-workflowconst res = await fetch(
"https://stackness.dev/api/v1/moves/public/prompt-driven-tdd-workflow"
);
const data = await res.json();Saved items
5Manage your bookmarks: save tools and moves for later, track their status as you evaluate them, and keep short notes.
Save a tool or a move to your bookmarks with an optional note. New items start with status "saved". Saving the same target twice returns a 409 conflict. A move you cannot see, a tool that is not approved, or an id that does not exist answers 404 TARGET_NOT_FOUND, checked before the conflict.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | "tool" or "move" |
| target_id | integer | Yes | ID of the tool or move to save |
| note | string | No | Optional note, at most 500 characters |
Response
{
"id": 42,
"target_type": "tool",
"target_id": 7,
"status": "saved",
"note": "Try for the side project",
"status_changed_at": "2025-01-20T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z",
"tool": {
"id": 7,
"name": "Neovim",
"slug": "neovim",
"description": "Hyperextensible Vim-based text editor",
"website_url": "https://neovim.io",
"logo_url": "https://cdn.stackness.dev/logos/neovim.png",
"categories": [
{ "id": 3, "name": "Editors", "slug": "editors", "display_order": 1 }
]
}
}curl -X POST https://stackness.dev/api/v1/saved-items \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"target_type": "tool", "target_id": 7, "note": "Try for the side project"}'const res = await fetch("https://stackness.dev/api/v1/saved-items", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_type: "tool", target_id: 7, note: "Try for the side project" }),
});
const data = await res.json();List the authenticated user's saved items, optionally filtered by status or target type. Cursor-based pagination; next_cursor is only present when has_more is true. Saved tools include an inline tool object with its categories, saved moves an inline move object with the id of the user who wrote it (author_id). A bookmarked move that is no longer public is left out until it turns public again.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| status | string | No | "saved", "trying", or "done" (query param) |
| type | string | No | "tool" or "move" (query param) |
| cursor | integer | No | Pagination cursor from the previous page's next_cursor (query param) |
| limit | integer | No | Items per page, default 20, max 100 (query param) |
Response
{
"items": [
{
"id": 42,
"target_type": "tool",
"target_id": 7,
"status": "trying",
"note": "Try for the side project",
"status_changed_at": "2025-01-21T09:00:00Z",
"created_at": "2025-01-20T10:00:00Z",
"tool": {
"id": 7,
"name": "Neovim",
"slug": "neovim",
"description": "Hyperextensible Vim-based text editor",
"website_url": "https://neovim.io",
"logo_url": "https://cdn.stackness.dev/logos/neovim.png",
"categories": [
{ "id": 3, "name": "Editors", "slug": "editors", "display_order": 1 }
]
}
},
{
"id": 41,
"target_type": "move",
"target_id": 12,
"status": "saved",
"status_changed_at": "2025-01-19T14:00:00Z",
"created_at": "2025-01-19T14:00:00Z",
"move": {
"id": 12,
"slug": "tdd-with-claude",
"title": "TDD with Claude",
"description": "Let the tests drive the agent",
"author_id": 5
}
}
],
"next_cursor": 41,
"has_more": true
}curl "https://stackness.dev/api/v1/saved-items?status=trying&limit=20" \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/saved-items?status=trying&limit=20", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Check whether the authenticated user has already saved a specific tool or move. When saved is false the item field is omitted. A bookmarked move that is no longer public reports saved false.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | "tool" or "move" (query param) |
| target_id | integer | Yes | ID of the tool or move to check (query param) |
Response
{
"saved": true,
"item": {
"id": 42,
"target_type": "tool",
"target_id": 7,
"status": "saved",
"note": "Try for the side project",
"status_changed_at": "2025-01-20T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z"
}
}curl "https://stackness.dev/api/v1/saved-items/check?target_type=tool&target_id=7" \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/saved-items/check?target_type=tool&target_id=7", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update the status or note of a saved item you own. At least one field must be provided. Changing the status also updates status_changed_at. A bookmark of a move that is no longer public answers the same 404 as an id that does not exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Saved item ID (path param) |
| status | string | No | "saved", "trying", or "done" |
| note | string | No | Updated note, at most 500 characters; send an empty string to clear it |
Response
{
"id": 42,
"target_type": "tool",
"target_id": 7,
"status": "trying",
"note": "Try for the side project",
"status_changed_at": "2025-01-21T09:00:00Z",
"created_at": "2025-01-20T10:00:00Z"
}curl -X PATCH https://stackness.dev/api/v1/saved-items/42 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"status": "trying"}'const res = await fetch("https://stackness.dev/api/v1/saved-items/42", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status: "trying" }),
});
const data = await res.json();Remove an item from your bookmarks. Returns 204 with no body on success; deleting an item you do not own returns 403. A bookmark of a move that is no longer public can still be deleted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Saved item ID (path param) |
Response
Empty response (204 no content)curl -X DELETE https://stackness.dev/api/v1/saved-items/42 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/saved-items/42", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Reactions
4React to stack tools, moves, stack updates, and blog posts with a fixed emoji set.
List reactions on a target grouped by emoji, sorted alphabetically, with aggregate counts. When called with a valid Authorization header, reacted is true for each emoji the caller has reacted with; for anonymous callers it is always false. A user_tool or move target that is not public, or does not exist, answers 404 TARGET_NOT_FOUND to everyone but its owner.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | "user_tool", "move", "stack_update", or "blog_post" (query param) |
| target_id | integer | Yes | ID of the target entity (query param) |
Response
{
"reactions": [
{
"emoji": "fire",
"count": 3,
"reacted": true
},
{
"emoji": "thumbsup",
"count": 5,
"reacted": false
}
]
}curl "https://stackness.dev/api/v1/reactions?target_type=move&target_id=12"const res = await fetch("https://stackness.dev/api/v1/reactions?target_type=move&target_id=12");
const data = await res.json();List reactions on a target grouped by emoji, sorted alphabetically, with the full count and up to 12 reactors per emoji, oldest reaction first. Reads an optional Authorization header: a user_tool or move target that is not public, or does not exist, answers 404 TARGET_NOT_FOUND to everyone but its owner. An unauthenticated caller gets the same 404 for a user_tool, move or stack_update target whose owner has set profile_visibility to logged_in, and never sees a reactor with a logged-in-only profile: the count still includes them, and the next reactor takes their place among the 12. Any signed-in viewer, including through an API key, sees every reactor.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | "user_tool", "move", "stack_update", or "blog_post" (query param) |
| target_id | integer | Yes | ID of the target entity (query param) |
Response
{
"reactions": [
{
"emoji": "fire",
"count": 3,
"users": [
{
"id": 7,
"username": "sarah_chen",
"display_name": "Sarah Chen",
"avatar_url": "https://stackness.dev/avatars/sarah_chen.png"
}
]
}
]
}curl "https://stackness.dev/api/v1/reactions/users?target_type=move&target_id=12"const res = await fetch("https://stackness.dev/api/v1/reactions/users?target_type=move&target_id=12");
const data = await res.json();Toggle your reaction on a target entity. Adding a new emoji returns 201 with the created reaction and created true; sending the same emoji again removes it and returns 200 with created false and no reaction object. Adding a reaction notifies the content owner. A user_tool or move target that is not public, or does not exist, answers 404 TARGET_NOT_FOUND to everyone but its owner, and nothing is written.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | "user_tool", "move", "stack_update", or "blog_post" |
| target_id | integer | Yes | ID of the entity to react to |
| emoji | string | Yes | "thumbsup", "fire", "lightbulb", "rocket", "heart", or "eyes" |
Response
{
"reaction": {
"id": 88,
"user_id": 7,
"target_type": "move",
"target_id": 12,
"emoji": "fire"
},
"created": true
}curl -X POST https://stackness.dev/api/v1/reactions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"target_type": "move", "target_id": 12, "emoji": "fire"}'const res = await fetch("https://stackness.dev/api/v1/reactions", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_type: "move", target_id: 12, emoji: "fire" }),
});
const data = await res.json();Delete a reaction by ID. Only the user who created the reaction can delete it. Returns 204 with an empty body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Reaction ID (path param) |
Response
204 no content (empty response body)curl -X DELETE https://stackness.dev/api/v1/reactions/88 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/reactions/88", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Feed
3Read the chronological feed of people you follow, the global updates feed, and per-user and per-tool feeds.
Get a chronological feed of activity from users you follow: stack changes, moves and stack updates.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 50) |
Response
{
"items": [
{
"id": "feed_456",
"type": "move",
"user": { "username": "janedoe", "display_name": "Jane Doe" },
"move": { "title": "Prompt-Driven TDD Workflow" },
"created_at": "2025-01-20T10:00:00Z"
}
],
"next_cursor": "abc123",
"has_more": true
}curl "https://stackness.dev/api/v1/feed/my?limit=20" \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/feed/my?limit=20", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Get the updates feed: recent activity of the users you follow. An event whose stack entry or move is not public, or no longer exists, is left out, and so is each stack update row naming one; a stack update left with no row and no note is skipped. Pages still hold limit items whenever more exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 50) |
Response
{
"items": [
{
"id": "feed_789",
"type": "stack_update",
"user": { "username": "johndoe" },
"title": "Q1 Stack Refresh",
"created_at": "2025-01-20T09:00:00Z"
}
],
"next_cursor": "def456",
"has_more": true
}curl "https://stackness.dev/api/v1/feed/updates?limit=20" \
-H "Authorization: Bearer <token>"const res = await fetch(
"https://stackness.dev/api/v1/feed/updates?limit=20",
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = await res.json();Get the public activity feed for a specific user. Returns 404 to an unauthenticated caller when the owner has set profile_visibility to logged_in; any signed-in viewer, including through an API key, sees the profile as usual. Anyone but the owner sees no event whose stack entry or move is not public or no longer exists, and no stack update row naming one; a stack update left with no row and no note is skipped. Pages still hold limit items whenever more exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | Username (path param) |
| cursor | string | No | Pagination cursor |
| limit | integer | No | Items per page (default 20, max 50) |
| around | integer | No | Stack update id to anchor the first page on, so a deep-linked update is returned however far down the timeline it sits. Ignored together with cursor, and ignored when the update has no event on this timeline, in which case the newest page is returned instead. |
Response
{
"items": [
{
"id": "feed_101",
"type": "stack_add",
"tool": { "name": "Bun", "slug": "bun" },
"created_at": "2025-01-20T10:00:00Z"
}
],
"next_cursor": "ghi789",
"has_more": false
}curl "https://stackness.dev/api/v1/users/janedoe/feed?limit=20"const res = await fetch(
"https://stackness.dev/api/v1/users/janedoe/feed?limit=20"
);
const data = await res.json();Notifications
4Read the authenticated user's notifications and mark them as read, one at a time or all at once.
List the authenticated user's notifications, newest first. type is one of follow, reaction, comment, copy, achievement, or game_unlocked; target_type is one of user_tool, move, stack_update, user, blog_post, user_achievement, or game. An achievement notification announces one earned level, its actor is the recipient themselves, and it carries an achievement object with the key, level and name; it is created only while achievements are switched on (see GET /api/v1/features) and is never emailed. A game_unlocked notification says a game is open to the recipient: its target_type is game, target_id is the game's id, its actor is the recipient themselves, and it carries a game object with the game's slug and name. It is in-app only and never emailed, not even in the digest. target_owner names the user who owns the target and is absent once that content is deleted (or, for a blog post, unpublished) - it is not always the recipient, since a reply notifies the parent comment's author about an item on someone else's profile. target_slug is set only on blog_post and game targets that still resolve and is what the post's /blog/{slug} URL, or the game's /games/{slug} page, is built from. A game target has no target_owner at all; it reaches someone only as a reply on that game's comment thread. Cursor-based pagination; next_cursor is only present when has_more is true.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| cursor | integer | No | Pagination cursor from the previous page's next_cursor (query param) |
| limit | integer | No | Items per page, default 20, max 100 (query param) |
Response
{
"notifications": [
{
"id": 120,
"type": "follow",
"target_type": "user",
"target_id": 1,
"is_read": false,
"created_at": "2025-01-20T10:00:00Z",
"actor": {
"id": 2,
"username": "johndoe",
"display_name": "John Doe",
"avatar_url": "https://cdn.stackness.dev/avatars/2.jpg"
},
"target_owner": {
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe"
}
},
{
"id": 119,
"type": "reaction",
"target_type": "user_tool",
"target_id": 15,
"is_read": true,
"created_at": "2025-01-19T18:30:00Z",
"actor": {
"id": 3,
"username": "alice",
"display_name": "Alice"
},
"target_owner": {
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe"
}
},
{
"id": 118,
"type": "comment",
"target_type": "stack_update",
"target_id": 88,
"is_read": true,
"created_at": "2025-01-19T12:00:00Z",
"actor": {
"id": 4,
"username": "toyblackhat",
"display_name": "Toy Black Hat"
},
"target_owner": {
"id": 4,
"username": "toyblackhat",
"display_name": "Toy Black Hat"
}
},
{
"id": 117,
"type": "reaction",
"target_type": "blog_post",
"target_id": 12,
"is_read": true,
"created_at": "2025-01-18T09:15:00Z",
"actor": {
"id": 3,
"username": "alice",
"display_name": "Alice"
},
"target_owner": {
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe"
},
"target_slug": "stackness-is-live-on-product-hunt"
},
{
"id": 116,
"type": "game_unlocked",
"target_type": "game",
"target_id": 1,
"is_read": false,
"created_at": "2025-01-17T08:00:00Z",
"actor": {
"id": 1,
"username": "janedoe",
"display_name": "Jane Doe"
},
"target_slug": "lonny-eagle",
"game": {
"slug": "lonny-eagle",
"name": "Lonny Eagle Amateur Skater 4"
}
}
],
"next_cursor": 116,
"has_more": true
}curl "https://stackness.dev/api/v1/notifications?limit=20" \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/notifications?limit=20", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Get the number of unread notifications for the authenticated user.
Response
{
"count": 3
}curl https://stackness.dev/api/v1/notifications/unread-count \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/notifications/unread-count", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Mark a single notification as read. Returns 204 with no body; a notification that does not exist or is not yours returns 404.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | integer | Yes | Notification ID (path param) |
Response
Empty response (204 no content)curl -X PATCH https://stackness.dev/api/v1/notifications/120/read \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/notifications/120/read", {
method: "PATCH",
headers: { Authorization: `Bearer ${token}` },
});Mark all of the authenticated user's unread notifications as read and return how many were updated. No request body is required.
Response
{
"marked_count": 5
}curl -X POST https://stackness.dev/api/v1/notifications/read-all \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/notifications/read-all", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Discover & search
7Discover trending tools and moves, top users, rising tools, browse by category, and full-text search.
Get tools trending across the platform based on recent adoption velocity: public stack entries added in the window, ended ones included, by owners who are neither banned nor deactivated. `user_count` counts the public entries that have not ended. Always returns at most 10 tools. The window widens from weekly to monthly, quarterly and all-time when the requested one is too quiet; the window actually used is echoed back.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| period | string | No | "weekly" (default) or "monthly" (query param) |
| category | string | No | Category slug (query param). Narrows the ranking to one tool category; an unknown slug returns 404. |
Response
{
"tools": [
{
"id": 20,
"name": "Vite",
"slug": "vite",
"user_count": 450,
"trend_score": 92
}
]
}curl "https://stackness.dev/api/v1/discover/trending-tools?period=weekly&category=ai-tools"const res = await fetch(
"https://stackness.dev/api/v1/discover/trending-tools?period=weekly&category=ai-tools"
);
const data = await res.json();Get the most popular moves on the platform right now. Only public moves that have not ended rank, by owners who are neither banned nor deactivated, and only reactions on them count. Always returns at most 10 moves, with the same widening window as trending tools. Reads an optional Authorization header: an unauthenticated caller never gets a move whose author has set profile_visibility to logged_in, checked at the moment of the request, and the list still fills from the cached ranking. Any signed-in viewer, including through an API key, gets them as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| category | string | No | Category slug (query param). Narrows the ranking to one tool category; an unknown slug returns 404. |
Response
{
"moves": [
{
"move_id": 5,
"title": "Prompt-Driven TDD Workflow",
"username": "janedoe",
"reaction_count": 42,
"comment_count": 7
}
]
}curl "https://stackness.dev/api/v1/discover/trending-moves?category=ai-tools"const res = await fetch(
"https://stackness.dev/api/v1/discover/trending-moves?category=ai-tools"
);
const data = await res.json();Get the top users leaderboard. Rows come in three tiers: people with at least one public, not ended tool first, then people without one, then generated accounts (is_bot). Inside a tier users are ranked by follower count, ties broken by user id ascending, so a person with no followers can rank above a generated account with many. Deactivated accounts are left out. Each row carries is_bot for generated accounts and the move_count and tool_count a visitor sees on the public profile (public and not ended, zero for a banned account); display_name, avatar_url and bio are omitted when empty. Always returns at most 10 users; the endpoint takes no parameters. Reads an optional Authorization header: an unauthenticated caller never sees a user whose profile_visibility is logged_in, checked at the moment of the request, and the list still fills with the next users. Any signed-in viewer, including through an API key, sees them as usual.
Response
{
"users": [
{
"user_id": 1,
"username": "janedoe",
"display_name": "Jane Doe",
"avatar_url": "https://example.com/avatars/janedoe.png",
"bio": "Backend engineer who writes about Go and Postgres.",
"is_pro": true,
"is_bot": false,
"follower_count": 1200,
"move_count": 20,
"tool_count": 25
}
]
}curl https://stackness.dev/api/v1/discover/top-usersconst res = await fetch(
"https://stackness.dev/api/v1/discover/top-users"
);
const data = await res.json();Get tools with the fastest adoption growth over the past week. Always returns at most 10 tools, with the same widening window as trending tools: window reports the period that answered (weekly, monthly, quarterly or all_time). growth_rate is recent_adds as a percentage of total_users, and only tools with at least 3 users qualify. recent_adds counts public stack entries added in the window, ended ones included; total_users counts the public entries that have not ended; both leave out owners who are banned or deactivated.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| category | string | No | Category slug (query param). Narrows the ranking to one tool category; an unknown slug returns 404. |
Response
{
"tools": [
{
"tool_id": 30,
"name": "Bun",
"slug": "bun",
"logo_url": "https://example.com/logos/bun.png",
"description": "All-in-one JavaScript runtime and toolkit",
"recent_adds": 6,
"total_users": 12,
"growth_rate": 50
}
],
"window": "weekly"
}curl "https://stackness.dev/api/v1/discover/rising-tools?category=ai-tools"const res = await fetch(
"https://stackness.dev/api/v1/discover/rising-tools?category=ai-tools"
);
const data = await res.json();Full-text search across tools, users, and moves. Filter by type for narrower results. Reads an optional Authorization header: an unauthenticated caller never finds a user whose profile_visibility is logged_in, or that user's moves, and any signed-in viewer, including through an API key, finds them as usual.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| q | string | Yes | Search query |
| type | string | No | Filter: "tools", "users", or "moves" |
| limit | integer | No | Max results (default 20, max 100) |
Response
{
"results": [
{
"type": "tool",
"tool": { "name": "PostgreSQL", "slug": "postgresql" }
},
{
"type": "user",
"user": { "username": "janedoe", "display_name": "Jane Doe" }
}
]
}curl "https://stackness.dev/api/v1/search?q=postgres&type=tools"const res = await fetch(
"https://stackness.dev/api/v1/search?q=postgres&type=tools"
);
const data = await res.json();The hand-curated stack showcases behind /stacks, alphabetically by title. Each showcase presents a few public profiles whose owners agreed to be featured and the tools they have in common. Members are checked on every read: someone who opts out, makes their profile logged-in only, is banned or deactivates drops out at once. A showcase with fewer than three eligible members is left out. `updated_at` is the later of the showcase's own edit and its newest member stack change.
Response
{
"stacks": [
{
"slug": "indie-hacker-stack",
"title": "Indie hacker stack",
"intro": "Solo founders who ship and run a product alone...",
"member_count": 6,
"updated_at": "2026-09-20T08:12:44Z"
}
]
}curl https://stackness.dev/api/v1/stacksconst res = await fetch("https://stackness.dev/api/v1/stacks");
const data = await res.json();One published showcase: its title and intro, the eligible members in the order they were curated (username, display name, avatar and the number of public tools in their stack), and the tools held by at least two of them. Each tool row carries its categories and `member_count`, read against the showcase's `member_count` as "{count} of {members}". Only public, current stack entries count. `data_as_of` is the newest public stack change among the members. An unknown slug, a draft and a showcase with fewer than three eligible members all return 404 with code `NOT_FOUND`.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Showcase slug (path param), e.g. indie-hacker-stack |
Response
{
"slug": "indie-hacker-stack",
"title": "Indie hacker stack",
"intro": "Solo founders who ship and run a product alone...",
"updated_at": "2026-09-20T08:12:44Z",
"data_as_of": "2026-09-20T08:12:44Z",
"member_count": 3,
"members": [
{
"username": "sarah_chen",
"display_name": "Sarah Chen",
"avatar_url": "https://cdn.stackness.dev/avatars/1.png",
"tool_count": 9
}
],
"tools": [
{
"slug": "next-js",
"name": "Next.js",
"logo_url": "https://cdn.stackness.dev/logos/next-js.png",
"logo_dark_url": "https://cdn.stackness.dev/logos/next-js-dark.png",
"member_count": 3,
"categories": [{ "slug": "frameworks", "name": "Frameworks" }]
}
]
}curl https://stackness.dev/api/v1/stacks/indie-hacker-stackconst res = await fetch(
"https://stackness.dev/api/v1/stacks/indie-hacker-stack"
);
const data = await res.json();Trends
1Historical popularity series (trend waves) for tools and moves, available to supporters.
Compute stacked popularity series for tools or moves over a time window. Requires a supporter subscription - accounts without one receive 403. Tool values count the public stack entries active in each bucket, by owners who are neither banned nor deactivated; move values are cumulative reaction counts on public moves that have not ended. Bands are picked by peak activity in the window, and each response includes the top rising and falling entries by growth percentage (999.99 marks a series that rose from zero). Results are cached server side for 6 hours.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| type | string | No | "tools" (default) or "moves" (query param) |
| range | string | No | "90d", "6m" (default), "12m", or "all" (query param) |
| granularity | string | No | "week" (default) or "month"; the history lens always uses "month", because every source it offers is monthly (query param) |
| category_id | integer | No | Limit tool activity to one category; applies to the tools type (query param) |
| limit | integer | No | Number of series to return, default 20, max 50 (query param) |
| ids | string | No | Comma-separated tool or move IDs to chart exactly, max 50; overrides limit, and ids with no activity in the window are omitted from the response (query param) |
| lens | string | No | "platform" (default) or "history"; the history lens attaches imported series to each band, and picks its tool bands among the tools with a reading from the source inside the window, platform-active ones first (query param) |
| history_source | string | No | Required when lens is "history": one of "wiki_pageviews", "hn_mentions", "npm", "sede_tags" or "pypi". Any other source is a 400 VALIDATION_ERROR; the rest stay on GET /api/v1/tools/{slug}/history (query param) |
| history_metric | string | No | Metric filter for the history lens, e.g. "pageviews", "question_count", "downloads" (query param) |
Response
{
"buckets": ["2025-01-06", "2025-01-13", "2025-01-20"],
"series": [
{
"id": 1,
"name": "PostgreSQL",
"slug": "postgresql",
"logo_url": "https://cdn.stackness.dev/logos/postgresql.png",
"values": [118, 124, 131]
}
],
"rising": [
{
"id": 1,
"name": "PostgreSQL",
"growth_pct": 11.0
}
],
"falling": [
{
"id": 7,
"name": "Grunt",
"growth_pct": -12.5
}
]
}curl "https://stackness.dev/api/v1/trends/waves?type=tools&range=6m&granularity=week" \
-H "Authorization: Bearer <token>"const res = await fetch(
"https://stackness.dev/api/v1/trends/waves?type=tools&range=6m&granularity=week",
{ headers: { Authorization: `Bearer ${token}` } },
);
const data = await res.json();Organizations
29Create and manage private organizations: their members, teams and invitations, and billing through Stripe. Every route under /api/v1/orgs/{slug} is for members only. A non-member and a missing organization get the same 404 with the body {"error":"organization not found","code":"NOT_FOUND"}, so a response never confirms that an organization exists. A member without the required role gets 403 FORBIDDEN.
Create a new organization. The creator becomes its owner. A new organization starts on the solo plan with a single seat and no teams; upgrade through billing to invite more members. Fails with 409 ORG_SLUG_TAKEN when the slug is in use or reserved (for example new, create, settings, admin or api).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Organization display name |
| slug | string | Yes | URL slug: lowercase letters, digits and single dashes, up to 100 characters, unique across Stackness |
| description | string | No | Short organization description |
Response
{
"id": 12,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"plan": "solo",
"seat_count": 1,
"max_seats": 1,
"created_at": "2025-01-20T10:00:00Z",
"role": "owner",
"member_count": 1,
"team_count": 0,
"settings": {
"team_views_counts_only": false,
"min_group_size": 5
}
}curl -X POST https://stackness.dev/api/v1/orgs \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Dev Team", "slug": "acme", "description": "Building the future of developer tools."}'const res = await fetch("https://stackness.dev/api/v1/orgs", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Acme Dev Team", slug: "acme" }),
});
const data = await res.json();List every organization the authenticated user belongs to, with the user's role in each: owner, admin or member.
Response
{
"organizations": [
{
"id": 12,
"name": "Acme Dev Team",
"slug": "acme",
"role": "owner"
}
]
}curl https://stackness.dev/api/v1/orgs \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Get an organization's profile, plan and seat usage, the viewer's role, member and team counts, and its settings. Members only. `team_views_counts_only` and `min_group_size` are stored and returned only for now.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
{
"id": 12,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"logo_url": "https://cdn.stackness.dev/orgs/acme.png",
"website": "https://acme.dev",
"plan": "pro",
"seat_count": 4,
"max_seats": 10,
"created_at": "2025-01-20T10:00:00Z",
"role": "owner",
"member_count": 4,
"team_count": 1,
"settings": {
"team_views_counts_only": false,
"min_group_size": 5
}
}curl https://stackness.dev/api/v1/orgs/acme \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update an organization's name, description, logo or website (owner or admin). Omitted fields are left unchanged; the full organization is returned.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| name | string | No | Updated organization name |
| description | string | No | Updated description |
| logo_url | string | No | Updated logo URL |
| website | string | No | Updated website URL |
Response
{
"id": 12,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"logo_url": "https://cdn.stackness.dev/orgs/acme.png",
"website": "https://acme.dev",
"plan": "pro",
"seat_count": 4,
"max_seats": 10,
"created_at": "2025-01-20T10:00:00Z",
"role": "owner",
"member_count": 4,
"team_count": 1,
"settings": {
"team_views_counts_only": false,
"min_group_size": 5
}
}curl -X PATCH https://stackness.dev/api/v1/orgs/acme \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"description": "Building the future of developer tools."}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ description: "Building the future of developer tools." }),
});
const data = await res.json();Delete an organization (owner only), together with its teams, memberships and invitations. Cancels the organization's Stripe subscription if one exists. Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/orgs/acme \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/orgs/acme", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Update the organization's privacy settings (owner or admin). Omitted fields are left unchanged; the full organization is returned. `min_group_size` must be between 5 and 50, otherwise the request fails with 400 VALIDATION_ERROR. Both settings are stored and returned only for now.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team_views_counts_only | boolean | No | Show team stack views as counts only, without per-person breakdowns. Default false |
| min_group_size | integer | No | Smallest group a team view may describe, 5 to 50. Default 5 |
Response
{
"id": 12,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"logo_url": "https://cdn.stackness.dev/orgs/acme.png",
"website": "https://acme.dev",
"plan": "pro",
"seat_count": 4,
"max_seats": 10,
"created_at": "2025-01-20T10:00:00Z",
"role": "owner",
"member_count": 4,
"team_count": 1,
"settings": {
"team_views_counts_only": false,
"min_group_size": 8
}
}curl -X PATCH https://stackness.dev/api/v1/orgs/acme/settings \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"min_group_size": 8}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme/settings", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ min_group_size: 8 }),
});
const data = await res.json();List the members of an organization with their roles and the teams each one belongs to. Members only. Offset-paginated: the page size defaults to 50 and is capped at 100. The `id` of each entry is the membership ID used to change or remove it.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| offset | integer | No | Number of members to skip, default 0 (query param) |
| limit | integer | No | Page size, default 50, max 100 (query param) |
Response
{
"members": [
{
"id": 3,
"user_id": 42,
"username": "sarah_chen",
"display_name": "Sarah Chen",
"avatar_url": "https://cdn.stackness.dev/avatars/sarah.png",
"role": "owner",
"teams": [
{
"id": 31,
"name": "Acme Dev Team",
"slug": "acme",
"role": "lead"
}
]
}
],
"total_count": 4,
"has_more": false
}curl https://stackness.dev/api/v1/orgs/acme/members \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/members", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Change an organization member's role (owner only). Setting the role to owner transfers ownership and makes the current owner an admin. The owner's own role only changes through a transfer (400 CANNOT_CHANGE_OWNER_ROLE). Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| id | integer | Yes | Organization membership ID (path param) |
| role | string | Yes | "member", "admin", or "owner" |
Response
204 no contentcurl -X PATCH https://stackness.dev/api/v1/orgs/acme/members/3 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'await fetch("https://stackness.dev/api/v1/orgs/acme/members/3", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ role: "admin" }),
});Remove a member from an organization (owner or admin). The owner cannot be removed, and only the owner can remove an admin. The member's team memberships in the organization go with it, and the seat count and Stripe subscription quantity are updated. Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| id | integer | Yes | Organization membership ID (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/orgs/acme/members/3 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/orgs/acme/members/3", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});List every invitation to the organization with its status (owner or admin). Status is one of pending, accepted, declined, cancelled, or expired. `team_ids` lists the teams the invitee joins on accepting.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
{
"invitations": [
{
"id": 7,
"email": "dev@example.com",
"role": "member",
"token": "3f9c2a71d0b64e88a5c1f2e3d4a5b6c7",
"status": "pending",
"expires_at": "2025-01-27T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z",
"team_ids": [31]
}
]
}curl https://stackness.dev/api/v1/orgs/acme/invitations \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/invitations", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Invite someone to the organization by email (owner or admin), optionally straight into some of its teams. The role defaults to member when omitted. An invitation email is sent and the invitation expires after 7 days. Fails with 409 DUPLICATE_INVITE when a pending invitation for the email already exists, 402 BILLING_REQUIRED or SEAT_LIMIT_REACHED when the organization has no free seats, and 400 VALIDATION_ERROR when a team ID belongs to another organization.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| string | Yes | Email address to invite | |
| role | string | No | "member" (default) or "admin" |
| team_ids | integer[] | No | IDs of this organization's teams to join on accepting |
Response
{
"id": 7,
"email": "dev@example.com",
"role": "member",
"token": "3f9c2a71d0b64e88a5c1f2e3d4a5b6c7",
"status": "pending",
"expires_at": "2025-01-27T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z",
"team_ids": [31]
}curl -X POST https://stackness.dev/api/v1/orgs/acme/invitations \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"email": "dev@example.com", "role": "member", "team_ids": [31]}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme/invitations", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "dev@example.com", role: "member", team_ids: [31] }),
});
const data = await res.json();Cancel an organization invitation (owner or admin). The invitation is marked cancelled and can no longer be accepted. Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| id | integer | Yes | Invitation ID (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/orgs/acme/invitations/7 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/orgs/acme/invitations/7", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});List the teams of an organization with their member counts. Members only.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
{
"teams": [
{
"id": 31,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"member_count": 4,
"created_at": "2025-01-20T10:00:00Z"
}
]
}curl https://stackness.dev/api/v1/orgs/acme/teams \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/teams", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Create a team inside the organization (owner or admin). The creator is not added to it. Fails with 409 TEAM_SLUG_TAKEN when the organization already has a team with that slug.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| name | string | Yes | Team display name |
| slug | string | Yes | URL slug: lowercase letters, digits and single dashes, unique within the organization |
| description | string | No | Short team description |
Response
{
"id": 32,
"name": "Design systems",
"slug": "design-systems",
"description": "Components and tokens for every Acme product.",
"member_count": 0,
"created_at": "2025-01-21T09:00:00Z"
}curl -X POST https://stackness.dev/api/v1/orgs/acme/teams \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Design systems", "slug": "design-systems"}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme/teams", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Design systems", slug: "design-systems" }),
});
const data = await res.json();Get one team of the organization by its slug. Members only. Fails with 404 TEAM_NOT_FOUND when the organization has no such team.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
Response
{
"id": 31,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"member_count": 4,
"created_at": "2025-01-20T10:00:00Z"
}curl https://stackness.dev/api/v1/orgs/acme/teams/acme \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Rename a team or change its description (a lead of that team, an admin or the owner). Omitted fields are left unchanged.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
| name | string | No | Updated team name |
| description | string | No | Updated description |
Response
{
"id": 31,
"name": "Acme Dev Team",
"slug": "acme",
"description": "Building the future of developer tools.",
"member_count": 4,
"created_at": "2025-01-20T10:00:00Z"
}curl -X PATCH https://stackness.dev/api/v1/orgs/acme/teams/acme \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"description": "Building the future of developer tools."}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ description: "Building the future of developer tools." }),
});
const data = await res.json();Delete a team and its memberships (owner or admin). The people stay in the organization. Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/orgs/acme/teams/acme \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});List the members of a team with their team roles, lead or member. Members of the organization only. Offset-paginated: the page size defaults to 50 and is capped at 100. The `id` of each entry is the team membership ID.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
| offset | integer | No | Number of members to skip, default 0 (query param) |
| limit | integer | No | Page size, default 50, max 100 (query param) |
Response
{
"members": [
{
"id": 21,
"user_id": 42,
"username": "sarah_chen",
"display_name": "Sarah Chen",
"avatar_url": "https://cdn.stackness.dev/avatars/sarah.png",
"role": "lead"
}
],
"total_count": 4,
"has_more": false
}curl https://stackness.dev/api/v1/orgs/acme/teams/acme/members \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme/members", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Add an organization member to a team (a lead of that team, an admin or the owner). The role defaults to member. Fails with 400 NOT_ORG_MEMBER when the user is not in the organization, and 409 ALREADY_MEMBER when they are already in the team.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
| user_id | integer | Yes | ID of the user to add |
| role | string | No | "member" (default) or "lead" |
Response
{
"id": 22,
"user_id": 43,
"username": "priya_codes",
"display_name": "Priya Sharma",
"avatar_url": "https://cdn.stackness.dev/avatars/priya.png",
"role": "member"
}curl -X POST https://stackness.dev/api/v1/orgs/acme/teams/acme/members \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"user_id": 43, "role": "member"}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme/members", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ user_id: 43, role: "member" }),
});
const data = await res.json();Make a team member a lead or a plain member (a lead of that team, an admin or the owner). Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
| id | integer | Yes | Team membership ID (path param) |
| role | string | Yes | "lead" or "member" |
Response
204 no contentcurl -X PATCH https://stackness.dev/api/v1/orgs/acme/teams/acme/members/22 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"role": "lead"}'await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme/members/22", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ role: "lead" }),
});Remove someone from a team (a lead of that team, an admin or the owner), or leave a team yourself. They stay in the organization. Returns 204 with no body.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| team | string | Yes | Team slug, unique within the organization (path param) |
| id | integer | Yes | Team membership ID (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/orgs/acme/teams/acme/members/22 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/orgs/acme/teams/acme/members/22", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Get the organization's billing contact and address (owner only). Unset fields are returned as empty strings.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
{
"billing_email": "billing@acme.dev",
"billing_name": "Acme GmbH",
"billing_address_line1": "Musterstrasse 1",
"billing_address_line2": "",
"billing_city": "Berlin",
"billing_state": "",
"billing_postal_code": "10115",
"billing_country": "DE"
}curl https://stackness.dev/api/v1/orgs/acme/billing/info \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/billing/info", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update the organization's billing contact and address (owner only). Omitted fields are left unchanged; the full billing info is returned.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
| billing_email | string | No | Billing contact email |
| billing_name | string | No | Billing name, such as the legal company name |
| billing_address_line1 | string | No | Address line 1 |
| billing_address_line2 | string | No | Address line 2 |
| billing_city | string | No | City |
| billing_state | string | No | State or region |
| billing_postal_code | string | No | Postal code |
| billing_country | string | No | Country code |
Response
{
"billing_email": "billing@acme.dev",
"billing_name": "Acme GmbH",
"billing_address_line1": "Musterstrasse 1",
"billing_address_line2": "",
"billing_city": "Berlin",
"billing_state": "",
"billing_postal_code": "10115",
"billing_country": "DE"
}curl -X PATCH https://stackness.dev/api/v1/orgs/acme/billing/info \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"billing_email": "billing@acme.dev", "billing_country": "DE"}'const res = await fetch("https://stackness.dev/api/v1/orgs/acme/billing/info", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ billing_email: "billing@acme.dev" }),
});
const data = await res.json();Create a Stripe checkout session that starts the organization's subscription (owner only). The seat quantity is adjustable in checkout. Returns the checkout URL to redirect the owner to.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
{
"url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3D4"
}curl -X POST https://stackness.dev/api/v1/orgs/acme/billing/upgrade \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/billing/upgrade", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Create a Stripe billing portal session for the organization (owner only). Requires a completed billing upgrade. Returns the portal URL to redirect the owner to.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | Organization slug (path param) |
Response
{
"url": "https://billing.stripe.com/p/session/live_a1B2c3D4"
}curl -X POST https://stackness.dev/api/v1/orgs/acme/billing/portal \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/orgs/acme/billing/portal", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();List pending, unexpired organization invitations addressed to the authenticated user's account email. `teams` lists the teams, still in existence, that accepting joins. The list stays empty until the account's email is verified.
Response
{
"invitations": [
{
"id": 7,
"token": "3f9c2a71d0b64e88a5c1f2e3d4a5b6c7",
"email": "dev@example.com",
"role": "member",
"expires_at": "2025-01-27T10:00:00Z",
"created_at": "2025-01-20T10:00:00Z",
"org_name": "Acme Dev Team",
"org_slug": "acme",
"org_logo_url": "https://cdn.stackness.dev/orgs/acme.png",
"inviter_name": "Sarah Chen",
"teams": [
{
"name": "Acme Dev Team",
"slug": "acme"
}
]
}
]
}curl https://stackness.dev/api/v1/invitations/pending \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/invitations/pending", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Get invitation details by token. Public - the token itself is the access credential, so the invitation page can render before the visitor signs in. A pending invitation past its expiry is reported with status expired. `teams` lists the teams, still in existence, that accepting joins.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Invitation token from the invitation email (path param) |
Response
{
"email": "dev@example.com",
"role": "member",
"status": "pending",
"expires_at": "2025-01-27T10:00:00Z",
"org_name": "Acme Dev Team",
"org_slug": "acme",
"org_logo_url": "https://cdn.stackness.dev/orgs/acme.png",
"inviter_name": "Sarah Chen",
"teams": [
{
"name": "Acme Dev Team",
"slug": "acme"
}
]
}curl https://stackness.dev/api/v1/invitations/3f9c2a71d0b64e88a5c1f2e3d4a5b6c7const res = await fetch(
"https://stackness.dev/api/v1/invitations/3f9c2a71d0b64e88a5c1f2e3d4a5b6c7",
);
const data = await res.json();Accept an organization invitation. Only the user whose account email matches the invitation email can accept, and only once that email is verified: an unverified account gets 403 EMAIL_NOT_VERIFIED. Joins the organization with the invited role and, in the same step, each listed team that still exists as a member, then updates the organization's seat count. Fails with 409 ALREADY_MEMBER when the user is already in the organization.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Invitation token (path param) |
Response
{
"status": "accepted"
}curl -X POST https://stackness.dev/api/v1/invitations/3f9c2a71d0b64e88a5c1f2e3d4a5b6c7/accept \
-H "Authorization: Bearer <token>"const res = await fetch(
"https://stackness.dev/api/v1/invitations/3f9c2a71d0b64e88a5c1f2e3d4a5b6c7/accept",
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
},
);
const data = await res.json();Decline an organization invitation. Only the user whose account email matches the invitation email can decline, and only once that email is verified: an unverified account gets 403 EMAIL_NOT_VERIFIED.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Invitation token (path param) |
Response
{
"status": "declined"
}curl -X POST https://stackness.dev/api/v1/invitations/3f9c2a71d0b64e88a5c1f2e3d4a5b6c7/decline \
-H "Authorization: Bearer <token>"const res = await fetch(
"https://stackness.dev/api/v1/invitations/3f9c2a71d0b64e88a5c1f2e3d4a5b6c7/decline",
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
},
);
const data = await res.json();Achievements
9Achievements, the daily streak and the games they unlock. Every route here answers 404 to everyone but admins while GET /api/v1/features reports achievements as false.
Return the whole catalog with the authenticated member's progress: per achievement the count toward it and its `unit` (what the count counts, such as "published stack updates"; empty for one-offs), the levels earned with their dates, the next level and how far it is, and the share of members holding each level (`rarity_percent`). One-off achievements have a single basic level; counted ones have bronze, silver and gold, and levels are never taken away. A secret achievement shows only the name "???" and a hint until it is earned. A game's own achievement is masked while the member has not unlocked its game and has not earned it: `game_locked` is true, its name reads "???", its description "Set a personal best in a game you have not unlocked yet." and its levels carry no `flavour`, while its count, unit, thresholds and next level stay. Lonny Eagle Amateur Skater 4's own achievement ("Do a kickflip!") is never masked, since that game is named to everyone. Also returns `earned_count` (distinct achievements at any level), the streak (current and best runs of active days, with one rest day in any seven forgiven, the dates visited over the last twelve weeks in the member's timezone, and `rest_day` with `rest_free_from`: the missed day the current streak forgave and the first day another miss is forgiven again, both null while the rest day is free) and the games with their unlock state, where a game the member has not unlocked comes back with an empty `name` and `slug` as in GET /api/v1/games, apart from Lonny Eagle Amateur Skater 4, which is always named. Levels the member's history has already reached are awarded on this read.
Response
{
"earned_count": 5,
"achievements": [
{
"key": "comments",
"name": "Well, actually",
"description": "Write comments.",
"unit": "comments",
"secret": false,
"game_locked": false,
"counted": true,
"count": 7,
"earned": true,
"highest_level": "bronze",
"levels": [
{
"level": "bronze",
"threshold": 5,
"flavour": "Five comments. You have opinions and you share them.",
"earned": true,
"unlocked_at": "2026-10-02T09:14:00Z",
"rarity_percent": 12.5
},
{
"level": "silver",
"threshold": 25,
"flavour": "Twenty-five comments. The threads are better for it.",
"earned": false,
"unlocked_at": null,
"rarity_percent": 3.1
}
],
"next": { "level": "silver", "threshold": 25, "remaining": 18 }
},
{
"key": "infinity",
"name": "???",
"description": "Set a personal best in a game you have not unlocked yet.",
"unit": "points in your best run",
"secret": false,
"game_locked": true,
"counted": true,
"count": 0,
"earned": false,
"highest_level": "",
"levels": [
{
"level": "bronze",
"threshold": 4000,
"flavour": "",
"earned": false,
"unlocked_at": null,
"rarity_percent": 0.8
}
],
"next": { "level": "bronze", "threshold": 4000, "remaining": 4000 }
},
{
"key": "konami",
"name": "???",
"description": "Some codes never get old",
"unit": "",
"secret": true,
"game_locked": false,
"counted": false,
"count": null,
"earned": false,
"highest_level": "",
"levels": [],
"next": null
}
],
"streak": {
"current": 4,
"best": 12,
"today": "2026-10-07",
"timezone": "Europe/Berlin",
"days": ["2026-10-03", "2026-10-04", "2026-10-06", "2026-10-07"],
"rest_day": "2026-10-05",
"rest_free_from": "2026-10-12"
},
"games": [
{
"id": 1,
"slug": "lonny-eagle",
"name": "Lonny Eagle Amateur Skater 4",
"requirement": 1,
"status": "available",
"unlocked": true,
"earned": 5,
"remaining": 0
},
{
"id": 4,
"slug": "",
"name": "",
"requirement": 10,
"status": "available",
"unlocked": false,
"earned": 5,
"remaining": 5
}
]
}curl https://stackness.dev/api/v1/achievements \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/achievements", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Unlock the secret achievement a code names. A code that unlocks nothing returns 404; a code the member already used returns 200 with `unlocked` false. Rate limited to 10 calls a minute.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | The secret code |
Response
{
"unlocked": true,
"achievement": {
"key": "konami",
"name": "Thirty lives",
"description": "Enter the Konami code on the achievements page.",
"unit": "",
"secret": true,
"game_locked": false,
"counted": false,
"count": null,
"earned": true,
"highest_level": "basic",
"levels": [
{
"level": "basic",
"threshold": 1,
"flavour": "Up, up, down, down. Use the extra lives wisely.",
"earned": true,
"unlocked_at": "2026-10-07T18:00:00Z",
"rarity_percent": 0.4
}
],
"next": null
}
}curl -X POST https://stackness.dev/api/v1/achievements/secret \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"code": "..."}'const res = await fetch("https://stackness.dev/api/v1/achievements/secret", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ code }),
});
const { unlocked } = await res.json();Return the games registry: each game's stable `id`, its requirement in distinct achievements earned at any level, and its status (`coming_soon` or `available`). The `id` never changes and is the target_id of the game's comment thread (target_type `game` on /api/v1/comments). A game whose `name` and `slug` are empty is a mystery, for one of two reasons: the caller has not unlocked it (signed out, that is every game but the first), or it is still an unnamed placeholder. Lonny Eagle Amateur Skater 4 opens at 1 achievement, which every member earns at signup, and is always named, to signed-out callers and members alike. Signed in, each game also carries `earned`, `remaining` and `unlocked` for the caller; signed out they are null and false.
Response
{
"games": [
{
"id": 1,
"slug": "lonny-eagle",
"name": "Lonny Eagle Amateur Skater 4",
"requirement": 1,
"status": "available",
"unlocked": true,
"earned": 4,
"remaining": 0
},
{
"id": 2,
"slug": "",
"name": "",
"requirement": 5,
"status": "available",
"unlocked": false,
"earned": 4,
"remaining": 1
},
{
"id": 4,
"slug": "",
"name": "",
"requirement": 10,
"status": "available",
"unlocked": false,
"earned": 4,
"remaining": 6
}
]
}curl "https://stackness.dev/api/v1/games"const res = await fetch("https://stackness.dev/api/v1/games");
const { games } = await res.json();Return one game with the same fields as an entry of GET /api/v1/games, including the caller's unlock state when signed in. Unlike the list, it names the game whether or not the caller has unlocked it, since the slug already does. An unnamed placeholder game answers 404 like an unknown slug.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | The game's slug (path param) |
Response
{
"id": 1,
"slug": "lonny-eagle",
"name": "Lonny Eagle Amateur Skater 4",
"requirement": 1,
"status": "available",
"unlocked": true,
"earned": 4,
"remaining": 0
}curl "https://stackness.dev/api/v1/games/lonny-eagle"const res = await fetch("https://stackness.dev/api/v1/games/lonny-eagle");
const game = await res.json();Return the top 50 members by their best accepted score, one row per member. Equal scores share a rank and the next rank skips; `tied` marks them. The weekly board counts runs finished since Monday 00:00 UTC, given in `since`. Generated bot accounts, banned and deactivated members and removed or rejected runs never appear. A member whose profile is visible to signed-in users only is never named to a signed-out caller: their row keeps its rank and score with `player` null. Signed in, `viewer` is the caller's own row wherever they rank and `nearby` the rows around it, up to two either side; both are null and empty otherwise. `total` counts every ranked member.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | The game's slug (path param) |
| period | string | No | all (default) or week |
Response
{
"game": "lonny-eagle",
"period": "week",
"since": "2026-10-05T00:00:00Z",
"total": 3,
"entries": [
{
"rank": 1,
"score": 4200,
"tied": false,
"player": {
"username": "janedoe",
"display_name": "Jane Doe",
"avatar_url": "https://cdn.stackness.dev/avatars/janedoe.png"
},
"viewer": true
},
{ "rank": 2, "score": 3100, "tied": true, "player": null, "viewer": false },
{
"rank": 2,
"score": 3100,
"tied": true,
"player": {
"username": "sam",
"display_name": "Sam",
"avatar_url": ""
},
"viewer": false
}
],
"viewer": {
"rank": 1,
"score": 4200,
"tied": false,
"player": {
"username": "janedoe",
"display_name": "Jane Doe",
"avatar_url": "https://cdn.stackness.dev/avatars/janedoe.png"
},
"viewer": true
},
"nearby": []
}curl "https://stackness.dev/api/v1/games/lonny-eagle/leaderboard?period=week"const res = await fetch(
"https://stackness.dev/api/v1/games/lonny-eagle/leaderboard?period=week",
);
const { entries } = await res.json();Return the authenticated member's personal best, how many of their runs counted (`play_count`) and the last ten of them, newest first. A game that reads back its best stats also gets `best_stats`, the best value of each across every counted run (Quack Overflow's `height`, in frames, which its personal best line marks, and Vibe Driver's `score`, which its menu shows), 0 before the first run. Rejected and removed runs never count.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | The game's slug (path param) |
Response
{
"game": "quack-overflow",
"best": {
"id": 812,
"score": 5889,
"duration_ms": 77312,
"finished_at": "2026-10-07T18:40:00Z"
},
"play_count": 23,
"recent": [
{
"id": 840,
"score": 1179,
"duration_ms": 14328,
"finished_at": "2026-10-07T19:02:00Z"
}
],
"best_stats": {
"height": 963
}
}curl https://stackness.dev/api/v1/games/quack-overflow/me \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/games/quack-overflow/me", {
headers: { Authorization: `Bearer ${token}` },
});
const { best, play_count, best_stats } = await res.json();Start a run of an available game the member has unlocked. Returns the run's id and a single-use token that finishes it, valid for the game's time limit: 30 minutes, or 2 hours for Lonny Eagle and Quack Overflow, whose endless runs can be paused. Answers 403 with GAME_LOCKED while the member has too few achievements and 409 with GAME_NOT_AVAILABLE for a game that has not shipped. Rate limited to 120 runs an hour per member.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | The game's slug (path param) |
Response
{
"run_id": 841,
"token": "q3Jx0w5...",
"expires_at": "2026-10-07T19:35:00Z"
}curl -X POST https://stackness.dev/api/v1/games/lonny-eagle/runs \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/games/lonny-eagle/runs", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const { run_id, token: runToken } = await res.json();Record a run's score with the run's token. The server measures the run from its start, and the game's validator judges the score against that time and `stats`, the game's own summary of the run. A plausible score returns `recorded` true with the personal best, `new_best` and `play_count`; an implausible one is kept out of every leaderboard and returns only `recorded` false. A token finishes its run once: a second finish answers 409 with RUN_FINISHED, a token that is not the run's 403 with INVALID_TOKEN and a run past its game's time limit 410 with RUN_EXPIRED. A finished run counts toward "I'm here just for games". Rate limited to 240 calls an hour per member.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | The game's slug (path param) |
| id | integer | Yes | The run id from the start call (path param) |
| token | string | Yes | The token from the start call |
| score | integer | Yes | The run's score |
| duration_ms | integer | No | How long the run lasted on the client, in milliseconds |
| stats | object | No | The game's summary of the run, such as tricks landed, up to 16 KB |
Response
{
"recorded": true,
"score": 4200,
"best": 4200,
"new_best": true,
"play_count": 24
}curl -X POST https://stackness.dev/api/v1/games/lonny-eagle/runs/841/finish \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"token": "q3Jx0w5...", "score": 4200, "duration_ms": 60000, "stats": {"tricks": 31}}'const res = await fetch(
`https://stackness.dev/api/v1/games/lonny-eagle/runs/${runId}/finish`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ token: runToken, score, duration_ms, stats }),
},
);
const { recorded, new_best } = await res.json();Return the achievements a member has earned, for their profile: earned levels only, with no counts, progress or locked entries. A game's own achievement whose game the caller has not unlocked (signed out, every one) is masked as in GET /api/v1/achievements, except Lonny Eagle Amateur Skater 4's ("Do a kickflip!"), which is never masked: `game_locked` is true, its name reads "???", its description "Set a personal best in a game you have not unlocked yet." and its levels carry no `flavour`. The member always sees their own unmasked. Answers 404, exactly like a missing member, when the member turned `show_achievements` off (except to the member themselves), when their profile is visible to signed-in users only and the caller is signed out, and for banned, deactivated or deleting accounts.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| username | string | Yes | The member's username (path param) |
Response
{
"username": "janedoe",
"earned_count": 2,
"gold_count": 0,
"achievements": [
{
"key": "hello",
"name": "It's dangerous to go alone",
"description": "Sign up for Stackness.",
"unit": "",
"secret": false,
"game_locked": false,
"counted": false,
"count": null,
"earned": true,
"highest_level": "basic",
"levels": [
{
"level": "basic",
"threshold": 1,
"flavour": "Take this: an account of your own.",
"earned": true,
"unlocked_at": "2026-10-01T08:00:00Z",
"rarity_percent": 100
}
],
"next": null
}
]
}curl "https://stackness.dev/api/v1/users/janedoe/achievements"const res = await fetch("https://stackness.dev/api/v1/users/janedoe/achievements");
const data = await res.json();Settings
12Manage notification preferences, privacy and blocked users, connected OAuth accounts, and account lifecycle: deletion, restore, and data export.
Return the authenticated user's notification preferences: the email delivery mode, per-event toggles and email lists. `product_updates_consented_at` is when the current product updates consent was given, and null while product updates are off. `notify_achievements` covers the game unlocked and achievements launch emails, and is on by default. `notify_following_digest` is the daily summary of what the people you follow added, dropped and posted, sent at 08:00 in your time zone when something is new. It is the effective value: on by default, but false for an account whose `email_notifications` is `none` until the member turns it on. `blog_posts` is the new posts email, about one a week, and `blog_posts_consented_at` is when it was turned on, null while it is off.
Response
{
"email_notifications": "realtime",
"notify_follows": true,
"notify_reactions": true,
"notify_comments": true,
"notify_copies": true,
"notify_achievements": true,
"trends_newsletter": true,
"notify_following_digest": true,
"product_updates": true,
"product_updates_consented_at": "2026-09-26T10:00:00Z",
"blog_posts": true,
"blog_posts_consented_at": "2026-10-15T09:00:00Z"
}curl https://stackness.dev/api/v1/settings/notifications \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/notifications", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update one or more notification preferences. Only fields present in the body are changed; the full updated settings are returned.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| email_notifications | string | No | "none", "realtime", or "digest" |
| notify_follows | boolean | No | Notify when someone follows you |
| notify_reactions | boolean | No | Notify when someone reacts to your content |
| notify_comments | boolean | No | Notify when someone comments on your content |
| notify_copies | boolean | No | Notify when someone copies a tool from your stack |
| notify_achievements | boolean | No | Email when your achievements unlock a game, and the one-off achievements launch email. "none" email notifications silence it too |
| notify_following_digest | boolean | No | The daily summary of what the people you follow did. true turns it on and records that you chose it, which also reaches an account whose email notifications are "none"; false turns it off |
| trends_newsletter | boolean | No | Receive the trends newsletter email |
| product_updates | boolean | No | Receive occasional product update emails. true records the consent time, false withdraws the consent and clears it |
| blog_posts | boolean | No | Receive the new posts email from the Stackness blog. true records the consent time, false withdraws the consent and clears it |
Response
{
"email_notifications": "digest",
"notify_follows": true,
"notify_reactions": false,
"notify_comments": true,
"notify_copies": true,
"notify_achievements": true,
"trends_newsletter": true,
"notify_following_digest": true,
"product_updates": false,
"product_updates_consented_at": null,
"blog_posts": false,
"blog_posts_consented_at": null
}curl -X PATCH https://stackness.dev/api/v1/settings/notifications \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"email_notifications": "digest", "notify_reactions": false}'const res = await fetch("https://stackness.dev/api/v1/settings/notifications", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email_notifications: "digest", notify_reactions: false }),
});
const data = await res.json();Return the authenticated user's privacy settings: default stack entry visibility, profile visibility, the activity feed opt-out, whether the user agrees to be featured in curated stack showcases, whether their earned achievements show on their profile, and the timezone their visits are dated in. `showcase_opt_in` is off by default and never appears on the public profile. `show_achievements` is on by default. An empty `timezone` means UTC.
Response
{
"default_visibility": "public",
"profile_visibility": "public",
"hide_activity": false,
"showcase_opt_in": false,
"show_achievements": true,
"timezone": "Europe/Berlin"
}curl https://stackness.dev/api/v1/settings/privacy \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/privacy", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Update one or more privacy settings. Only fields present in the body are changed; the full updated settings are returned.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| default_visibility | string | No | Default visibility for new stack entries: "public", "friends", or "private" |
| profile_visibility | string | No | Who can view your profile: "public" or "logged_in" |
| hide_activity | boolean | No | Hide your tool additions, moves, and stack updates from other users' feeds |
| showcase_opt_in | boolean | No | Allow Stackness to feature your username, display name, avatar and public stack on curated /stacks pages. Applies only while your profile is public |
| show_achievements | boolean | No | Show your earned achievements on your profile. Off, GET /api/v1/users/{username}/achievements answers 404 for everyone but you |
| timezone | string | No | IANA zone name your visits are dated in for the streak, such as "Europe/Berlin". Empty means UTC; an unknown zone returns 400 VALIDATION_ERROR. Days already recorded keep their dates |
Response
{
"default_visibility": "friends",
"profile_visibility": "public",
"hide_activity": true,
"showcase_opt_in": false,
"show_achievements": true,
"timezone": ""
}curl -X PATCH https://stackness.dev/api/v1/settings/privacy \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"default_visibility": "friends", "hide_activity": true}'const res = await fetch("https://stackness.dev/api/v1/settings/privacy", {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ default_visibility: "friends", hide_activity: true }),
});
const data = await res.json();List all users the authenticated user has blocked, newest first. display_name and avatar_url are omitted when empty.
Response
{
"users": [
{
"id": 42,
"username": "johndoe",
"display_name": "John Doe",
"avatar_url": "https://stackness.dev/avatars/johndoe.png",
"blocked_at": "2025-01-20T10:00:00Z"
}
]
}curl https://stackness.dev/api/v1/settings/privacy/blocked \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/privacy/blocked", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Remove a block on the given user. Returns 204 no content on success, or 404 if the user is not blocked.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| userID | integer | Yes | ID of the blocked user to unblock (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/settings/privacy/blocked/42 \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/settings/privacy/blocked/42", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});List the OAuth providers linked to the authenticated user's account. The array is empty when no provider is connected.
Response
{
"accounts": [
{
"provider": "github",
"email": "jane@example.com",
"connected_at": "2025-01-20T10:00:00Z"
}
]
}curl https://stackness.dev/api/v1/settings/accounts \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/accounts", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Start linking a GitHub or Google account to the authenticated user. Returns the OAuth authorization URL to redirect the user to; the link completes on the OAuth callback.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| provider | string | Yes | "github" or "google" (path param) |
Response
{
"url": "https://github.com/login/oauth/authorize?client_id=Iv1.abc123&state=..."
}curl -X POST https://stackness.dev/api/v1/settings/accounts/github \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/accounts/github", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Unlink a GitHub or Google account. Fails with 409 when it is the last remaining sign-in method: a password or another provider must remain. Returns 204 no content on success.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| provider | string | Yes | "github" or "google" (path param) |
Response
204 no contentcurl -X DELETE https://stackness.dev/api/v1/settings/accounts/github \
-H "Authorization: Bearer <token>"await fetch("https://stackness.dev/api/v1/settings/accounts/github", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});Request deletion of the authenticated user's account. Re-authentication is required: send your current password, or your exact username when the account is OAuth-only and has no password. API keys and refresh tokens are revoked immediately, and the account is erased after a 30-day grace period during which it can be restored. Fails with 409 when a deletion is already pending or you are the sole owner of an organization (LAST_ORG_OWNER).
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| password | string | No | Current password; required when the account has a password |
| username_confirm | string | No | Exact username; required for OAuth-only accounts without a password |
Response
{
"deletion_requested_at": "2025-01-20T10:00:00Z",
"grace_period_days": 30
}curl -X DELETE https://stackness.dev/api/v1/settings/account \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"password": "your-password"}'const res = await fetch("https://stackness.dev/api/v1/settings/account", {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ password: "your-password" }),
});
const data = await res.json();Cancel a pending account deletion during the 30-day grace period. Fails with 409 when no deletion is pending.
Response
{
"message": "account restored"
}curl -X POST https://stackness.dev/api/v1/settings/account/restore \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/account/restore", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Request an export of your account data. Returns 202 and runs in the background: a JSON archive is assembled and a download link valid for 7 days arrives by email. Limited to 2 requests per hour.
Response
{
"message": "export started: a download link will arrive by email"
}curl -X POST https://stackness.dev/api/v1/settings/account/export \
-H "Authorization: Bearer <token>"const res = await fetch("https://stackness.dev/api/v1/settings/account/export", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();Uploads
2Upload avatar and content images to Stackness storage and get back a CDN URL.
Upload a profile avatar as a multipart form. Accepts JPEG, PNG, WebP, and GIF up to 2 MB; the type is detected from the file content, not the filename. The image is center-cropped to 256x256 and re-encoded as JPEG before storage. Returns 201 with the CDN URL of the stored file.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| file | file | Yes | Image file: JPEG, PNG, WebP, or GIF, max 2 MB (multipart form field) |
Response
{
"url": "https://cdn.stackness.dev/avatars/1/3f2a9c1e-7b4d-4e2a-9c1e-7b4d4e2a9c1e.jpg"
}curl -X POST https://stackness.dev/api/v1/upload/avatar \
-H "Authorization: Bearer <token>" \
-F "file=@avatar.png"const form = new FormData();
form.append("file", fileInput.files[0]);
const res = await fetch("https://stackness.dev/api/v1/upload/avatar", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const data = await res.json();Upload a general-purpose image (screenshots, cover images) as a multipart form. Accepts JPEG, PNG, WebP, and GIF up to 5 MB; the type is detected from the file content and the image is stored in its original format. Returns 201 with the CDN URL of the stored file.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| file | file | Yes | Image file: JPEG, PNG, WebP, or GIF, max 5 MB (multipart form field) |
Response
{
"url": "https://cdn.stackness.dev/images/1/3f2a9c1e-7b4d-4e2a-9c1e-7b4d4e2a9c1e.png"
}curl -X POST https://stackness.dev/api/v1/upload/image \
-H "Authorization: Bearer <token>" \
-F "file=@screenshot.png"const form = new FormData();
form.append("file", fileInput.files[0]);
const res = await fetch("https://stackness.dev/api/v1/upload/image", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const data = await res.json();Blog
6Read published Stackness blog posts. Every post carries one category: update, announcement, trend, knowledge (evergreen explainers and guides) or report (the quarterly data reports). A post with a downloadable dataset carries dataset_url, an absolute https link to a CSV; posts without one omit the field.
List published blog posts, newest first by published_at with the id breaking ties, with cursor pagination. Drafts are never returned here. category, tag, tool and q combine, so every filter given must match. Pass fields=summary to leave each post's body out when a list only shows cards; every other field, word_count and reading_time_minutes included, stays. dataset_url, an absolute https link to the post's CSV download, is present only on posts that have one. Reads an optional Authorization header: when a post's author has set profile_visibility to logged_in, an unauthenticated caller gets the post without author and with author_hidden set to true, which tells it apart from a post whose author was deleted. Any signed-in viewer, including through an API key, gets the author as usual, and author_hidden is omitted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| cursor | string | No | The next_cursor of the previous page, passed back unchanged (query param). It is an opaque string. A bare post id, the format older clients hold, still resumes after that post, and a cursor that names nothing usable returns the first page rather than an error. |
| limit | integer | No | Page size, default 20, max 100 (query param) |
| category | string | No | One of "update", "announcement", "trend", "knowledge" or "report" (query param). Any other value returns 400 VALIDATION_ERROR. |
| q | string | No | Search text (query param). Returns posts whose title or excerpt contains it, ignoring case, or that carry a tag containing its slug form, so context engineering finds context-engineering. Surrounding spaces are trimmed and anything past 100 characters is ignored. |
| fields | string | No | Set to "summary" to leave body out of every post (query param). Any other value returns the full shape. |
| tool | string | No | Tool slug (query param). Returns only published posts whose body links that tool page, newest first. An unknown slug returns an empty page, not a 404. |
| tag | string | No | Tag slug (query param). Returns only published posts carrying that tag, with the same cursor pagination. The value is normalised like stored tags, so AI matches ai. An unused tag returns an empty page. |
Response
{
"posts": [
{
"id": 3,
"title": "Introducing trend waves",
"slug": "introducing-trend-waves",
"body": "Full markdown body of the post...",
"excerpt": "A new Pro chart showing how tool adoption shifts over time.",
"cover_image_url": "https://cdn.stackness.dev/images/1/cover.png",
"dataset_url": "https://cdn.stackness.dev/reports/2026-Q3/usage-by-category.csv",
"status": "published",
"category": "announcement",
"published_at": "2025-06-02T09:00:00Z",
"created_at": "2025-06-01T14:30:00Z",
"updated_at": "2025-06-02T09:00:00Z",
"author": {
"id": 1,
"username": "sarah_chen",
"display_name": "Sarah Chen",
"avatar_url": "https://cdn.stackness.dev/avatars/1/a1b2c3.jpg"
},
"tags": ["trends", "pro"],
"word_count": 640,
"reading_time_minutes": 4,
"mentioned_tools": [
{
"id": 12,
"name": "Git",
"slug": "git",
"logo_url": "https://cdn.stackness.dev/logos/git.png"
}
]
}
],
"next_cursor": "1780390800000000000_3",
"has_more": true
}curl "https://stackness.dev/api/v1/blog?limit=20&category=trend"const res = await fetch(
"https://stackness.dev/api/v1/blog?limit=20&category=trend"
);
const data = await res.json();How many published posts each category and each tag holds, for building filters. total counts every published post. categories and tags leave out any value no published post carries, and each list is ordered by count, highest first, then alphabetically. Tags are the lowercase slugs the tag filter on GET /api/v1/blog accepts. Drafts never count.
Response
{
"total": 36,
"categories": [
{ "category": "knowledge", "count": 21 },
{ "category": "trend", "count": 10 },
{ "category": "update", "count": 3 },
{ "category": "announcement", "count": 2 }
],
"tags": [
{ "tag": "coding-agents", "count": 17 },
{ "tag": "claude-code", "count": 16 },
{ "tag": "benchmarks", "count": 7 }
]
}curl https://stackness.dev/api/v1/blog/facetsconst res = await fetch("https://stackness.dev/api/v1/blog/facets");
const { total, categories, tags } = await res.json();Get a single published blog post by its URL slug. Returns 404 if no published post has that slug. The body is sanitised HTML whose h2, h3 and h4 headings carry stable id anchors. tags are lowercase slugs, word_count and reading_time_minutes (200 words a minute, at least 1) are derived from the body, and mentioned_tools lists the catalog tools the body links, each with user_count, the number of members with the tool in their public stack (the same count the tool page shows; this endpoint only, omitted when the count is unavailable). dataset_url is an absolute https link to the post's downloadable CSV, omitted when the post has none. previous and next (slug, title, published_at) point at the published posts in the same category directly before and after this one by publication date; each is omitted when there is no such post. Reads an optional Authorization header: when a post's author has set profile_visibility to logged_in, an unauthenticated caller gets the post without author and with author_hidden set to true, which tells it apart from a post whose author was deleted. Any signed-in viewer, including through an API key, gets the author as usual, and author_hidden is omitted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | URL slug of the post (path param) |
Response
{
"id": 3,
"title": "Introducing trend waves",
"slug": "introducing-trend-waves",
"body": "Full markdown body of the post...",
"excerpt": "A new Pro chart showing how tool adoption shifts over time.",
"cover_image_url": "https://cdn.stackness.dev/images/1/cover.png",
"dataset_url": "https://cdn.stackness.dev/reports/2026-Q3/usage-by-category.csv",
"status": "published",
"category": "announcement",
"published_at": "2025-06-02T09:00:00Z",
"created_at": "2025-06-01T14:30:00Z",
"updated_at": "2025-06-02T09:00:00Z",
"author": {
"id": 1,
"username": "sarah_chen",
"display_name": "Sarah Chen",
"avatar_url": "https://cdn.stackness.dev/avatars/1/a1b2c3.jpg"
},
"tags": ["trends", "pro"],
"word_count": 640,
"reading_time_minutes": 4,
"mentioned_tools": [
{
"id": 12,
"name": "Git",
"slug": "git",
"logo_url": "https://cdn.stackness.dev/logos/git.png",
"user_count": 214
}
],
"previous": {
"slug": "introducing-moves",
"title": "Introducing moves",
"published_at": "2025-05-12T09:00:00Z"
}
}curl https://stackness.dev/api/v1/blog/introducing-trend-wavesconst res = await fetch(
"https://stackness.dev/api/v1/blog/introducing-trend-waves",
);
const data = await res.json();Other published posts related to a published post. When the post has related posts picked by hand in Batcave, the published ones are returned in the order they were picked. Otherwise the posts sharing the most tags come first, newest first among equals, and when no post shares a tag, the newest posts in the same category are returned instead. The post itself and drafts are never included, and the list can be empty. Returns 404 if no published post has that slug. Reads an optional Authorization header: when a post's author has set profile_visibility to logged_in, an unauthenticated caller gets the post without author and with author_hidden set to true, which tells it apart from a post whose author was deleted. Any signed-in viewer, including through an API key, gets the author as usual, and author_hidden is omitted.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| slug | string | Yes | URL slug of the post (path param) |
| limit | integer | No | Maximum posts to return, default 3, max 10 (query param) |
Response
{
"posts": [
{
"id": 5,
"title": "Trend waves, three months in",
"slug": "trend-waves-three-months-in",
"body": "<p>Full HTML body of the post...</p>",
"excerpt": "What the first quarter of trend waves taught us.",
"status": "published",
"category": "trend",
"published_at": "2025-09-01T09:00:00Z",
"created_at": "2025-08-30T10:00:00Z",
"updated_at": "2025-09-01T09:00:00Z",
"author": {
"id": 1,
"username": "sarah_chen",
"display_name": "Sarah Chen"
},
"tags": ["trends"],
"word_count": 980,
"reading_time_minutes": 5,
"mentioned_tools": []
}
]
}curl "https://stackness.dev/api/v1/blog/introducing-trend-waves/related?limit=3"const res = await fetch(
"https://stackness.dev/api/v1/blog/introducing-trend-waves/related?limit=3",
);
const { posts } = await res.json();Start a subscription to new blog posts for a reader without an account. The answer is the same `{"ok": true}` for every valid address, whether it is new, waiting for confirmation, already subscribed or belongs to a member, so the endpoint never tells who is on the list. A new or unconfirmed address gets one email with a confirmation link that works once and expires after 3 days; nothing else is sent to it until that link is confirmed. A member's address gets a pointer to the new blog posts switch in notification settings and nothing is stored for it. A second request for the same address within 10 minutes sends nothing. Needs a Cloudflare Turnstile token where Turnstile is configured, and takes 5 requests an hour per IP.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| string | Yes | The address to send new posts to, at most 254 characters. | |
| source_path | string | No | The blog post the box was on, such as /blog/osc-7501. Anything that is not a /blog/ path is dropped. |
| cf_turnstile_token | string | No | The Cloudflare Turnstile response. Required where Turnstile is configured; a failed check answers 400 CAPTCHA_FAILED. |
Response
{
"ok": true
}curl -X POST https://stackness.dev/api/v1/blog/subscribe \
-H "Content-Type: application/json" \
-d '{"email": "sam@example.com", "source_path": "/blog/osc-7501", "cf_turnstile_token": "TURNSTILE_RESPONSE"}'const res = await fetch("https://stackness.dev/api/v1/blog/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "sam@example.com",
source_path: "/blog/osc-7501",
cf_turnstile_token: turnstileResponse,
}),
});
const { ok } = await res.json();Confirm a blog subscription with the token from the confirmation email, and record that moment as the reader's consent. The token is read from the JSON body only and is sent by the button on the confirmation page, so opening the emailed link changes nothing on its own. It works once: a used, expired or unknown token answers 400 INVALID_BLOG_SUBSCRIBE_TOKEN. Rate limited per IP.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | The token from the confirmation link. |
Response
{
"ok": true
}curl -X POST https://stackness.dev/api/v1/blog/subscribe/confirm \
-H "Content-Type: application/json" \
-d '{"token": "CONFIRMATION_TOKEN"}'const res = await fetch("https://stackness.dev/api/v1/blog/subscribe/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const { ok } = await res.json();Billing
5Read the supporter price, check a subscription, and open Stripe checkout or the customer portal.
Returns the public price of the supporter plan. Public so logged out pages can show it. Amounts are in the smallest currency unit, so 489 chf means CHF 4.89. When pricing cannot be resolved the response is still a 200 with available set to false, and the other fields are zero values.
Response
{
"supporter": {
"available": true,
"amount": 489,
"currency": "chf",
"interval": "month",
"interval_count": 1
}
}curl "https://stackness.dev/api/v1/billing/plans"const res = await fetch("https://stackness.dev/api/v1/billing/plans");
const { supporter } = await res.json();Returns the plan, subscription status and renewal date of the authenticated account. billing_enabled is false when the deployment has no Stripe credentials, in which case every account reads as free.
Response
{
"plan": "supporter",
"status": "active",
"current_period_end": "2026-09-16T00:00:00Z",
"cancel_at_period_end": false,
"billing_enabled": true
}curl "https://stackness.dev/api/v1/billing/status" \
-H "Authorization: Bearer YOUR_TOKEN"const res = await fetch("https://stackness.dev/api/v1/billing/status", {
headers: { Authorization: `Bearer ${token}` },
});
const status = await res.json();Creates a Stripe checkout session for the authenticated account and returns the URL to send the browser to. Returns 409 when the account is already subscribed, and 503 when billing is not configured.
Response
{
"url": "https://checkout.stripe.com/c/pay/cs_test_..."
}curl -X POST "https://stackness.dev/api/v1/billing/checkout" \
-H "Authorization: Bearer YOUR_TOKEN"const res = await fetch("https://stackness.dev/api/v1/billing/checkout", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const { url } = await res.json();Creates a Stripe customer portal session so the account can change its payment method or cancel. Returns 400 when the account has never had a subscription, and 503 when billing is not configured.
Response
{
"url": "https://billing.stripe.com/p/session/live_..."
}curl -X POST "https://stackness.dev/api/v1/billing/portal" \
-H "Authorization: Bearer YOUR_TOKEN"const res = await fetch("https://stackness.dev/api/v1/billing/portal", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
const { url } = await res.json();Applies a finished checkout session to the authenticated account without waiting for the Stripe webhook, so the success page can show the new plan immediately. Safe to call more than once for the same session.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| session_id | string | Yes | Stripe checkout session id returned on the success redirect |
Response
{
"status": "ok"
}curl -X POST "https://stackness.dev/api/v1/billing/verify-session" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"session_id": "cs_test_a1b2c3"}'const res = await fetch(
"https://stackness.dev/api/v1/billing/verify-session",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ session_id: sessionId }),
},
);
const data = await res.json();Feedback and reports
2Send feedback to the founder and report content or users for moderation.
Submit a contact or feedback message. No authentication is required, but a bearer token may be sent - when it is, the message is linked to that account. Rate limited to 5 submissions per hour per IP. Returns 201 with the stored message ID. The body is either JSON or a multipart form with the same field names; only the multipart form can carry images, sent as up to 3 repeated `images` file parts. The text fields and the captcha are checked before any image is stored. Image errors: 413 FILE_TOO_LARGE (an image over 5 MB, or a body over the multipart cap), 400 INVALID_FILE_TYPE (not JPEG, PNG, WebP or GIF by content), 400 TOO_MANY_FILES (more than 3 images), 502 UPLOAD_FAILED (storage refused an image; nothing is kept), 503 UPLOAD_UNAVAILABLE (image storage is not configured). Text field errors stay 400 VALIDATION_FAILED.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Sender name, max 120 characters |
| string | Yes | Reply-to email address, max 254 characters | |
| subject | string | Yes | Message subject, max 200 characters |
| message | string | Yes | Message body, max 5000 characters |
| cf_turnstile_token | string | No | Cloudflare Turnstile captcha token; verified when captcha is enabled on the deployment |
| images | file | No | Multipart form only: up to 3 images, repeated as `images` file parts. JPEG, PNG, WebP or GIF, max 5 MB each; the type is detected from the file content, not the filename |
Response
{
"ok": true,
"id": 42
}curl -X POST https://stackness.dev/api/v1/feedback \
-H "Content-Type: application/json" \
-d '{"name": "Ada Lovelace", "email": "ada@example.com", "subject": "Feature idea", "message": "It would be great to compare two stacks side by side."}'
# With screenshots attached (multipart form)
curl -X POST https://stackness.dev/api/v1/feedback \
-F "name=Ada Lovelace" \
-F "email=ada@example.com" \
-F "subject=Button looks wrong" \
-F "message=The save button overlaps the footer on mobile." \
-F "images=@screenshot-1.png" \
-F "images=@screenshot-2.png"const res = await fetch("https://stackness.dev/api/v1/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Ada Lovelace",
email: "ada@example.com",
subject: "Feature idea",
message: "It would be great to compare two stacks side by side.",
}),
});
const data = await res.json();Report content or a user for moderation review. Each account can report a given target once; reporting the same target again returns 409. Returns 201 with the created report, which starts in the pending status.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | "user_tool", "move", "comment", or "user" |
| target_id | integer | Yes | ID of the reported content or user |
| reason | string | Yes | "spam", "harassment", "phishing", "inappropriate", or "other" |
| reason_detail | string | No | Free-text detail explaining the report |
Response
{
"id": 15,
"reporter_id": 4,
"target_type": "comment",
"target_id": 128,
"reason": "spam",
"reason_detail": "Repeated link drops for the same site",
"status": "pending",
"created_at": "2025-01-20T10:00:00Z",
"updated_at": "2025-01-20T10:00:00Z"
}curl -X POST https://stackness.dev/api/v1/reports \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"target_type": "comment", "target_id": 128, "reason": "spam"}'const res = await fetch("https://stackness.dev/api/v1/reports", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_type: "comment", target_id: 128, reason: "spam" }),
});
const data = await res.json();System
4Identify the build the API is running, read the launch switches, opt out of bulk email, and turn on the daily summary.
Returns which switched features are on. `achievements` is the ACHIEVEMENTS_ENABLED switch: while it is false every achievements, streak and games route answers 404 to everyone but admins. Read it at runtime; it changes with a backend restart, not a frontend build. Touches no database or cache.
Response
{
"achievements": true
}curl "https://stackness.dev/api/v1/features"const res = await fetch("https://stackness.dev/api/v1/features");
const { achievements } = await res.json();Turn off one bulk email list for the recipient a signed token identifies. Deliberately unauthenticated: it is the target of the List-Unsubscribe one-click header, which mail providers post to with no session attached. The token is read from the query string when the body carries none, and it names the list it applies to, so a newsletter token cannot switch off the digest. `list` in the response is `newsletter` (the weekly trends newsletter), `digest` (the notification digest), `product_updates` (the occasional product update emails), `achievements` (the game unlocked and achievements launch emails), `following_digest` (the daily summary and its one-time welcome-back email) or `blog_posts` (the new posts email). A blog subscriber without an account has a token of its own kind, which no member token can stand in for and the other way round; `account` in the response is false for such a subscriber and true for a member. Repeat calls succeed unchanged. Tokens do not expire; they are minted per recipient in each bulk message.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Signed opt-out token from the email. Accepted in the JSON body or as a query parameter. |
Response
{
"list": "newsletter",
"message": "You have been unsubscribed from the weekly trends newsletter.",
"account": true
}curl -X POST "https://stackness.dev/api/v1/unsubscribe?token=NDI6bmV3c2xldHRlcg.SIGNATURE"const res = await fetch("https://stackness.dev/api/v1/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: "NDI6bmV3c2xldHRlcg.SIGNATURE" }),
});
const data = await res.json();Turn on the daily summary for the member a signed subscribe token identifies, and record that they chose it. Unauthenticated, because the button that leads here sits in the one-time welcome-back email, which may be opened signed out. The token comes from that button and is read from the JSON body only, so following the link alone changes nothing. It is signed for subscribing, so no unsubscribe token is accepted here, and a subscribe token is rejected by `POST /api/v1/unsubscribe`. Rate limited per IP. Repeat calls succeed unchanged.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Signed subscribe token from the welcome-back email. |
Response
{
"list": "following_digest",
"message": "Your daily summary is on."
}curl -X POST https://stackness.dev/api/v1/subscribe \
-H "Content-Type: application/json" \
-d '{"token": "c3Vic2NyaWJlOjQyOmZvbGxvd2luZ19kaWdlc3Q.SIGNATURE"}'const res = await fetch("https://stackness.dev/api/v1/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: "c3Vic2NyaWJlOjQyOmZvbGxvd2luZ19kaWdlc3Q.SIGNATURE" }),
});
const data = await res.json();Returns the commit and build time of the API process serving the request, plus its environment. Touches no database or cache, so it is safe to poll. The values are empty strings when the process was not started by the deploy pipeline.
Response
{
"version": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
"built_at": "2026-08-16T09:30:00Z",
"env": "production"
}curl "https://stackness.dev/api/v1/version"const res = await fetch("https://stackness.dev/api/v1/version");
const data = await res.json();To connect an AI assistant instead, follow the MCP server documentation.
Comments
4Comment on stack tools, moves, stack updates, blog posts and games, with one level of threaded replies.
List root comments on a target, oldest first, with up to 3 of each root's replies interleaved after it; reply_count carries the full reply total per root. Pass parent_id to page through all replies of one root comment instead; total_count is then that parent's full reply count, otherwise it counts every comment on the target, roots and replies included. Reads an optional Authorization header: a user_tool or move target that is not public, or no longer exists, answers 404 TARGET_NOT_FOUND to everyone but its owner, and a parent_id on such a target answers 404 PARENT_NOT_FOUND like a missing parent. An unauthenticated caller gets the same 404s for a user_tool, move or stack_update target whose owner has set profile_visibility to logged_in; blog_post targets are never gated by their author. A game target (target_id is the id from GET /api/v1/games) has a thread only for a signed-in member who has unlocked that game, and only once it is available: everyone else, including for a game that is a mystery, coming soon or unknown, and every non-admin while achievements are switched off (see GET /api/v1/features), gets 404 TARGET_NOT_FOUND, and a parent_id on that thread gets 404 PARENT_NOT_FOUND. Comments that a user with a logged-in-only profile posted elsewhere stay in the list for an unauthenticated caller, without user and with user_id 0. Any signed-in viewer, including through an API key, sees every author as usual, and total_count and reply_count count every comment for everyone.
Parameters
Response
Create a comment on a target entity, or reply to a root comment via parent_id (threads are one level deep, so replying to a reply is rejected). Comments containing profanity are rejected. The content owner is notified, and a reply also notifies the parent comment's author, unless the target is not public, when only its owner hears about it. A user_tool or move target that is not public, or does not exist, answers 404 TARGET_NOT_FOUND to everyone but its owner, before parent_id is checked. A game has no owner, so a comment on one notifies nobody and a reply notifies only the parent comment's author; only a member who has unlocked the available game can post on it, and anyone else gets 404 TARGET_NOT_FOUND under the same rule as listing. Returns 201 with the created comment.
Parameters
Response
Update the body of one of your comments. Only the comment owner can edit; the same length and profanity checks as creation apply. A comment on a game first needs the caller to pass that game's thread rule from GET /api/v1/comments, or it answers 404 TARGET_NOT_FOUND. Returns the updated comment.
Parameters
Response
Delete a comment. Allowed for the comment owner and for the owner of the content the comment is on. A game has no owner, so only the author deletes a comment on one, after passing that game's thread rule from GET /api/v1/comments, or it answers 404 TARGET_NOT_FOUND. Returns 204 with an empty body.
Parameters
Response