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
| Plan | Daily limit |
|---|---|
| Free | API access denied (403) |
| Pro | 100 requests/day |
| Whitelabel | 1000 requests/day |
Every response includes:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset(Unix epoch seconds)
A 429 also includes Retry-After (seconds) and a body with resetAt ISO timestamp.
Error codes
| Code | Meaning |
|---|---|
| 400 | Validation failed. Inspect details.fieldErrors for specifics. |
| 401 | Missing or invalid API key. |
| 402 | Feature requires a higher plan. required + current in the body. |
| 403 | API access not permitted for this plan, or resource belongs to another user. |
| 404 | Resource not found. |
| 413 | Payload exceeds a per-plan object cap (e.g. 10 lead magnets on Pro). |
| 429 | Rate limit exceeded. Retry after resetAt. |
| 503 | Upstream 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
GETandDELETEare safe to retry.POSTis not yet server-side idempotent. If a retry is likely (flaky network), check for a duplicate with a follow-up GET before retrying.PUTupdates 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, successMessagewaitlist-- label, showCount, referralEnablednewsletter-- label, provider (mailchimp/convertkit/substack/native), formAction?lead_magnet-- label, leadMagnetIdbook_call-- label, provider (calendly/cal_com), urlbuy_now-- label, stripeLink, priceDisplaypreorder-- label, stripeLink, depositAmount (cents, min 100)discord_join-- label, inviteUrl (must starthttps://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.jsonis the authoritative contract; if this guide disagrees with the spec, the spec wins.