Skip to content

Developer docs

Makefolio API

Build your portfolio from anything that can speak HTTP -- a CLI, an MCP tool, a custom script. Machine-readable spec: /api/v1/openapi (JSON) or /api/v1/openapi.yaml.

The Makefolio HTTP API lets AI agents (Claude CLI, MCP clients, Cursor, custom scripts) fully author and update a portfolio without touching the dashboard. This guide is the human-readable companion to the machine-readable OpenAPI spec.

Authentication

All requests require a personal API key sent in the x-api-key header. Keys are prefixed with mkfl_.

Generate a key from /dashboard/settings. The full key is shown once at creation -- copy it then. Rotate with delete + generate.

curl https://makefolio.com/api/v1/profile \
  -H "x-api-key: mkfl_your_key_here"

API access requires a Pro or Whitelabel plan. Free users receive 403 plan_required on any v1 endpoint.

Base URL and versioning

  • Production: https://makefolio.com/api/v1
  • Spec version: 1.0.1 (phase 4.5)

Breaking changes bump the path to /api/v2. Additive changes (new fields, new routes, new error codes) happen in-place with a x-version bump in the OpenAPI info block.

Rate limits

PlanDaily limit
FreeAPI access denied (403)
Pro100 requests/day
Whitelabel1000 requests/day

Every response includes:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset (Unix epoch seconds)

A 429 also includes Retry-After (seconds) and a body with resetAt ISO timestamp.

Error codes

CodeMeaning
400Validation failed. Inspect details.fieldErrors for specifics.
401Missing or invalid API key.
402Feature requires a higher plan. required + current in the body.
403API access not permitted for this plan, or resource belongs to another user.
404Resource not found.
413Payload exceeds a per-plan object cap (e.g. 10 lead magnets on Pro).
429Rate limit exceeded. Retry after resetAt.
503Upstream service (S3) not configured on the server.

Every error body follows:

{
  "error": "plan_required",
  "required": "pro",
  "current": "free",
  "message": "This feature requires a Pro plan. Upgrade at /dashboard/billing."
}

Pagination

List endpoints default to 50 items sorted by createdAt desc. For /projects/{id}/leads pass ?limit=100&cursor=<iso> where cursor is the nextCursor from the previous response. Cursor is the createdAt of the last returned lead.

Idempotency

  • GET and DELETE are safe to retry.
  • POST is not yet server-side idempotent. If a retry is likely (flaky network), check for a duplicate with a follow-up GET before retrying.
  • PUT updates use full-replacement semantics for the fields supplied; omitted fields are left untouched.

Quickstart: read your profile

curl https://makefolio.com/api/v1/profile \
  -H "x-api-key: $MAKEFOLIO_API_KEY"

Quickstart: create a project

curl -X POST https://makefolio.com/api/v1/projects \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Phoenix",
    "tagline": "a 90-day relaunch",
    "shortDescription": "From 12 signups a month to 400.",
    "stage": "launched",
    "category": "saas-tool",
    "techStack": ["Next.js", "Postgres", "Stripe"],
    "visibilityPublic": true,
    "tags": ["relaunch", "growth"]
  }'

Response: { "data": { "_id": "...", "slug": "phoenix-xxxxx", ... } } with HTTP 201.

Quickstart: attach a CTA to a project

CTAs are Pro-gated. Free users get 402 on this call.

curl -X PUT https://makefolio.com/api/v1/projects/$PROJECT_ID \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ctaConfig": {
      "type": "buy_now",
      "label": "Buy for $49",
      "stripeLink": "https://buy.stripe.com/xxx",
      "priceDisplay": "$49 one-time"
    }
  }'

CTA variants (the type field discriminates):

  • email_capture -- label, placeholder, successMessage
  • waitlist -- label, showCount, referralEnabled
  • newsletter -- label, provider (mailchimp/convertkit/substack/native), formAction?
  • lead_magnet -- label, leadMagnetId
  • book_call -- label, provider (calendly/cal_com), url
  • buy_now -- label, stripeLink, priceDisplay
  • preorder -- label, stripeLink, depositAmount (cents, min 100)
  • discord_join -- label, inviteUrl (must start https://discord.gg/)

Quickstart: set per-project SEO

SEO is Pro-gated.

curl -X PUT https://makefolio.com/api/v1/projects/$PROJECT_ID \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "seo": {
      "title": "Phoenix -- the 90-day relaunch",
      "description": "A teardown of how we went from 12 to 400 weekly signups.",
      "geoFaq": [
        { "question": "What platform is Phoenix built on?", "answer": "Next.js 14 + Postgres + Stripe." },
        { "question": "Is the source available?", "answer": "No, the business is closed-source." }
      ]
    }
  }'

geoFaq entries render on the public project page as an FAQ accordion and as FAQPage JSON-LD for search engines and LLM answer engines.

Upload flow (3 steps)

Images (avatar, hero, project-cover, section-image) and lead magnets (PDF, zip, etc.) use the same 3-step flow.

1. Presign

curl -X POST https://makefolio.com/api/v1/upload/presign \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename":"cover.png","contentType":"image/png","context":"project-cover"}'

Response: { "data": { "uploadUrl": "https://...s3...", "key": "originals/..." } }. The URL is valid for 5 minutes.

2. Upload bytes to S3

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  --data-binary @cover.png

3. Confirm

curl -X POST https://makefolio.com/api/v1/upload/confirm \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"originals/...","context":"project-cover"}'

Response for image contexts: { "data": { "originalUrl", "optimizedUrl", "thumbnailUrl" } }. The optimized URL is what you save to coverImageUrl / avatarUrl / etc.

Response for context: "lead-magnet": all three URLs are the original (Sharp processing is skipped for non-images).

Runnable Node example

import { readFile } from "node:fs/promises"

const API = "https://makefolio.com/api/v1"
const KEY = process.env.MAKEFOLIO_API_KEY

async function uploadImage(localPath, context) {
  const filename = localPath.split("/").pop()
  const contentType = filename.endsWith(".png") ? "image/png" : "image/jpeg"

  const presignRes = await fetch(`${API}/upload/presign`, {
    method: "POST",
    headers: { "x-api-key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ filename, contentType, context }),
  })
  const { data: { uploadUrl, key } } = await presignRes.json()

  const bytes = await readFile(localPath)
  await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": contentType },
    body: bytes,
  })

  const confirmRes = await fetch(`${API}/upload/confirm`, {
    method: "POST",
    headers: { "x-api-key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ key, context }),
  })
  const { data } = await confirmRes.json()
  return data.optimizedUrl
}

Lead magnets

Upload the file first (context lead-magnet), then register it:

curl -X POST https://makefolio.com/api/v1/lead-magnets \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Launch week playbook",
    "description": "PDF checklist -- 28 pages.",
    "fileUrl": "https://...s3...amazonaws.com/originals/...pdf",
    "fileName": "playbook.pdf",
    "fileSize": 482130,
    "contentType": "application/pdf"
  }'

Pro plan cap: 10 lead magnets. Hitting the cap returns 413 lead_magnet_limit_reached.

Once created, reference the magnet in a project CTA:

{
  "ctaConfig": {
    "type": "lead_magnet",
    "label": "Get the playbook",
    "leadMagnetId": "65f..."
  }
}

Leads

Read the leads captured for a project (paginated):

curl "https://makefolio.com/api/v1/projects/$PROJECT_ID/leads?limit=100" \
  -H "x-api-key: $MAKEFOLIO_API_KEY"

Export all leads across all your projects as a signed CSV URL:

curl -X POST https://makefolio.com/api/v1/leads/export \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"'"$PROJECT_ID"'"}'

Response includes downloadUrl (signed for 300 seconds) and count. Exported leads are stamped with exportedAt so the dashboard can show "already exported" badges.

Sections (case study content)

Sections are the content blocks on a project's case study page. Eight types; content shape varies by type:

  • text -- { heading?, body }
  • image -- { url, caption?, alt? }
  • image-grid -- { images: [{ url, caption?, alt? }] }
  • quote -- { quote, attribution? }
  • metrics -- { metrics: [{ label, value }] }
  • code-block -- { language, code, caption? }
  • video-embed -- { url, caption? }
  • before-after -- { beforeUrl, afterUrl, caption? }
curl -X POST https://makefolio.com/api/v1/projects/$PROJECT_ID/sections \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "metrics",
    "content": { "metrics": [
      { "label": "Weekly signups", "value": "400" },
      { "label": "MRR", "value": "$6,200" }
    ]}
  }'

Reorder sections (full list of ordered UUIDs):

curl -X PUT https://makefolio.com/api/v1/projects/$PROJECT_ID/sections/reorder \
  -H "x-api-key: $MAKEFOLIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"orderedIds":["<uuid1>","<uuid2>","<uuid3>"]}'

Webhooks

Webhooks are coming soon. The planned events are lead.captured, subscription.changed, and project.published. Until then, poll /api/v1/projects/{id}/leads or the export endpoint.

MCP

Makefolio ships an MCP (Model Context Protocol) server in the repo at packages/mcp. It wraps every v1 endpoint as an LLM-friendly tool so a Claude CLI can read the spec once and then operate on the API end-to-end. See the packages/mcp/README.md for install instructions.

Getting help

  • Open an issue on the repo.
  • The OpenAPI spec at /api/v1/openapi.json is the authoritative contract; if this guide disagrees with the spec, the spec wins.