Public API

Yoofi API v1

Yoofi exposes versioned REST endpoints for public portal reads plus authenticated idea creation, voting, and follow workflows.

Base URL

Use your deployment host plus /v1. Locally that is usually http://localhost:3000/v1.

Public contract

Routes under /api power the web app and can change without notice. Treat /v1 as the stable integration surface.

Quick start

The seed script creates a demo portal at /portal/public and an API key named yoofi_dev_key_123456789 for local development.

Read ideas

curl "http://localhost:3000/v1/portals/public/ideas?status=PLANNED&sort=top"

Create an idea

curl -X POST "http://localhost:3000/v1/portals/public/ideas" \
  -H "Authorization: Bearer yoofi_dev_key_123456789" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Add dark mode for portal pages",
    "description": "Customers need a dark theme for public portal browsing on low-light devices.",
    "categoryId": null,
    "tagIds": []
  }'

Vote for an idea

curl -X POST "http://localhost:3000/v1/ideas/IDEA_ID/vote" \
  -H "X-API-Key: yoofi_dev_key_123456789"

List response excerpt

{
  "items": [
    {
      "id": "cm9exampleidea",
      "title": "Publish webhook retries with delivery logs",
      "description": "Expose webhook retry history so integration teams can debug failures without support tickets.",
      "status": "PLANNED",
      "targetTimeframe": "Q2 2026",
      "category": {
        "id": "cm9category",
        "name": "Integrations",
        "slug": "integrations"
      },
      "tags": [
        {
          "tag": {
            "id": "cm9tag",
            "name": "API",
            "slug": "api"
          }
        }
      ],
      "_count": {
        "votes": 1,
        "follows": 1
      }
    }
  ],
  "page": 1,
  "pageSize": 10,
  "total": 1,
  "totalPages": 1
}

Authentication

Write endpoints accept either a browser session or an API key.

Session cookie

Use the same authenticated browser session as the web app. This is the easiest option for in-product integrations.

Bearer token

Send an API key in the Authorization header as Bearer <key>.

X-API-Key header

If you cannot set Authorization, send the same key in X-API-Key.

Scopes

Yoofi checks scopes only on authenticated write operations today.

public:read

Read access for public resources. Current GET /v1 routes do not require authentication, but this scope is included on session and API-key principals.

ideas:write

Required for POST /v1/portals/:portalSlug/ideas.

votes:write

Required for vote, unvote, follow, and unfollow operations.

Conventions

A few behaviors are shared across the public API.

TopicValueDetails
Base path/v1Public, versioned REST routes live under /v1. Internal web-app routes under /api are not part of the public contract.
Content typeapplication/jsonWrite routes expect JSON request bodies. Responses are JSON for both success and error cases.
PaginationpageThe ideas list accepts page. The current page size is fixed at 10 items per page.
Idea statusesUNDER_REVIEW, PLANNED, COMPLETEDUse these exact enum values when filtering or interpreting roadmap results.
Idea sort valuestop, new, trendingInvalid sort values fall back to top.

Endpoint reference

The details below match the current route handlers in the app.

GET

/v1/portals/:portalSlug/ideas

Public

List ideas for a portal with optional search, filtering, sorting, and pagination.

Query parameters

NameTypeRequiredDescription
qstringNoSearch title and description.
statusUNDER_REVIEW | PLANNED | COMPLETEDNoFilter by idea status.
tagstringNoFilter by tag slug.
categorystringNoFilter by category slug.
sorttop | new | trendingNoOrdering mode. Defaults to top.
pagenumberNo1-based page number. Values below 1 are treated as 1.

Response

  • Returns { items, page, pageSize, total, totalPages }.
  • Each item includes category, tags with nested tag records, and _count for votes and follows.
  • Returns 404 with code portal_not_found if the portal slug does not exist.

Notes

  • Only canonical, non-deleted ideas are returned.
  • The response does not currently include the full tag/category filter catalog even though the server resolves it internally.
POST

/v1/portals/:portalSlug/ideas

Session or API key with ideas:write

Create a new idea inside a portal that belongs to the authenticated workspace.

Request body

NameTypeRequiredDescription
titlestringYes5-140 characters.
descriptionstringYes10-5000 characters.
categoryIdstring | nullNoOptional category id from the same workspace.
tagIdsstring[]NoOptional tag ids from the same workspace.

Response

  • Returns 201 with { id } on success.
  • Returns 403 if the authenticated principal belongs to a different workspace than the portal.
  • Returns 400 validation_failed for malformed bodies or invalid category ids.

Notes

  • Unknown or archived tag ids are ignored rather than rejected.
  • The new idea is created in UNDER_REVIEW unless later changed by admin tooling.
GET

/v1/ideas/:ideaId

Public

Fetch a single idea record, including counts, tags, category, and the latest idea events.

Response

  • Returns { item, canonicalIdeaId, merged }.
  • item includes tags, category, _count.{votes,follows}, and up to 50 recent events ordered newest first.
  • Returns 404 with code idea_not_found if the idea does not exist or was deleted.

Notes

  • merged is true when canonicalIdeaId is set, which means the idea was merged into another record.
  • This route returns the idea by id directly and is not scoped by portal slug.
POST

/v1/ideas/:ideaId/vote

Session or API key with votes:write

Vote for an idea. Voting also ensures the caller follows the idea.

Response

  • Returns { voteCount } after the mutation.
  • The operation is idempotent: repeated POST requests keep a single vote.
  • Returns 404 if the idea is deleted, merged, missing, or outside the authenticated workspace.
DELETE

/v1/ideas/:ideaId/vote

Session or API key with votes:write

Remove the caller vote from an idea.

Response

  • Returns { voteCount } after deletion.
  • If the vote does not exist, the request still succeeds and returns the current count.
POST

/v1/ideas/:ideaId/follow

Session or API key with votes:write

Follow an idea without affecting its vote count.

Response

  • Returns { isFollowing: true } on success.
  • The operation is idempotent and keeps a single follow record per user.
DELETE

/v1/ideas/:ideaId/follow

Session or API key with votes:write

Stop following an idea.

Response

  • Returns { isFollowing: false } after deletion.
  • If the follow does not exist, the request still succeeds.
GET

/v1/tags

Public

List active tags for a workspace.

Query parameters

NameTypeRequiredDescription
workspacestringNoWorkspace slug. If omitted, the first workspace in the database is used.

Response

  • Returns { items } where items is an array of non-archived tag records.
  • If the workspace is missing, the endpoint returns { items: [] }.
GET

/v1/announcements

Public

List published announcements for a portal.

Query parameters

NameTypeRequiredDescription
portalSlugstringNoPortal slug. If omitted, the first public portal is used.

Response

  • Returns { items } ordered by publishedAt descending.
  • Each item includes createdBy.displayName.
  • Draft announcements and unpublished announcements are excluded.
GET

/v1/roadmap

Public

Return the current roadmap buckets for a portal.

Query parameters

NameTypeRequiredDescription
portalSlugstringNoPortal slug. If omitted, the first public portal is used.

Response

  • Returns { underReview, planned, completed }.
  • Each array contains canonical, non-deleted ideas with category, tags, and _count.
  • If the portal is missing, all three arrays are returned empty.

Idea detail response excerpt

GET /v1/ideas/:ideaId returns the idea record plus merge metadata.

{
  "item": {
    "id": "cm9exampleidea",
    "title": "Add advanced trend filters on analytics dashboard",
    "status": "UNDER_REVIEW",
    "canonicalIdeaId": null,
    "category": {
      "id": "cm9category",
      "name": "User Experience",
      "slug": "ux"
    },
    "tags": [
      {
        "tag": {
          "id": "cm9tag",
          "name": "Analytics",
          "slug": "analytics"
        }
      }
    ],
    "_count": {
      "votes": 2,
      "follows": 2
    },
    "events": [
      {
        "id": "cm9event",
        "type": "CREATED",
        "payload": "{\"message\":\"Idea submitted\"}"
      }
    ]
  },
  "canonicalIdeaId": null,
  "merged": false
}

Error format

Public API errors use a consistent JSON envelope.

{
  "code": "validation_failed",
  "message": "Title must contain at least 5 character(s)",
  "requestId": "0f5b5cc4-5db1-40bb-9f74-df0d497f0eb4"
}

Common codes

  • auth_failed: Missing session, invalid API key, or missing required scope.
  • portal_not_found: Unknown portal slug on list or create routes.
  • idea_not_found: Unknown, deleted, merged, or out-of-workspace idea for the current route.
  • validation_failed: Body parsing failed or a field failed validation.
  • forbidden: Authenticated principal tried to write into a different workspace.