{"openapi":"3.1.0","info":{"title":"nmorais.dev API","version":"1.0.0","summary":"Read-only JSON API behind the nmorais.dev portfolio.","description":"The same data the site renders: profile, projects, experience, education and skills, in English or Portuguese. Read-only, no authentication. Responses carry an ETag (send If-None-Match for a 304), CORS is open to any origin, and errors use RFC 9457 problem details.","contact":{"name":"Nuno Morais","url":"https://nmorais.dev"}},"servers":[{"url":"/","description":"Same origin as the site."}],"tags":[{"name":"Profile"},{"name":"Projects"},{"name":"Career"},{"name":"Meta"}],"paths":{"/v1/nuno":{"get":{"operationId":"getProfile","summary":"Profile summary","description":"Role, years of experience, location, main stacks and spoken languages. The hero panel on the home page renders this same payload.","tags":["Profile"],"parameters":[{"$ref":"#/components/parameters/lang"}],"responses":{"200":{"description":"Profile summary.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Content-Language":{"$ref":"#/components/headers/ContentLanguage"},"x-location":{"description":"Where Nuno is based.","schema":{"type":"string"},"example":"Porto, PT"},"x-open-to-work":{"description":"Whether Nuno is open to new roles.","schema":{"type":"string","enum":["true","false"]},"example":"true"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Profile"},"example":{"role":"Full Stack Developer","years":4,"location":"Porto, Portugal","backend":["Node.js","Hono","Express"],"frontend":["React","Next.js"],"data":["MongoDB","SQL","D1"],"cloud":["Cloudflare Workers"],"languages":["en","pt"]}}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/projects":{"get":{"operationId":"listProjects","summary":"List projects","description":"Every project, with a featured flag for the ones on the home page.","tags":["Projects"],"parameters":[{"$ref":"#/components/parameters/lang"},{"name":"featured","in":"query","required":false,"description":"Only featured (true) or only non-featured (false) projects.","schema":{"type":"string","enum":["true","false"]}}],"responses":{"200":{"description":"Projects, in display order.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Content-Language":{"$ref":"#/components/headers/ContentLanguage"}},"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}},"example":[{"slug":"ao-minuto","name":"Ao Minuto","description":"Real-time, location-based platform that aggregates and structures official public data (e.g., Civil Protection incidents) to present relevant events near the user.","stack":["Next.js","Tailwind CSS","TypeScript","Cloudflare Workers","Hono"],"featured":true,"url":"https://aominuto.pt","repo":null},{"slug":"yatec","name":"YATEC","description":"Open-source temporary email service on the Cloudflare Edge, with a REST API, SQL database (Cloudflare D1/SQLite), and a Next.js interface. Built to be simple, low-cost, and self-hostable using only Cloudflare services.","stack":["Next.js","Tailwind CSS","TypeScript","Cloudflare Workers","Hono","Cloudflare D1 (SQLite)","Cloudflare Email Routing"],"featured":true,"url":"https://yatec.xyz","repo":"https://github.com/NunoD87/yatec"},{"slug":"recaptv","name":"RecapTV","description":"TV series and movie tracker aggregating metadata and availability from TVDB, TMDB, and JustWatch.","stack":["Next.js","Tailwind CSS","TypeScript","Cloudflare Workers","Hono","Cloudflare D1 (SQLite)"],"featured":true,"url":"https://recaptv.app","repo":null},{"slug":"the-village-cosplay","name":"The Village Cosplay","description":"Website for a Portuguese cosplay group built around Minecraft. The page is a playable village with mineable blocks, villagers and an enchanting table, plus events, a gallery and bilingual content managed in a CMS.","stack":["Next.js","Tailwind CSS","TypeScript","Cloudflare Workers","Hono","Strapi"],"featured":false,"url":"https://thevillagecosplay.pt","repo":null}]}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/projects/{slug}":{"get":{"operationId":"getProject","summary":"Open a project","description":"Redirects (302) to the project's live site. Browsers follow redirects without showing them, so send `X-Preview: 1` or `?preview=1` to get a 200 with the redirect target as JSON instead.","tags":["Projects"],"parameters":[{"name":"slug","in":"path","required":true,"description":"Project slug.","schema":{"type":"string","enum":["ao-minuto","yatec","recaptv","the-village-cosplay"]},"example":"ao-minuto"},{"name":"X-Preview","in":"header","required":false,"description":"Set to 1 to get the redirect as JSON (200) instead of a 302.","schema":{"type":"string","enum":["1"]}},{"name":"preview","in":"query","required":false,"description":"Same as the X-Preview header.","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"Preview of the redirect (X-Preview: 1 or ?preview=1).","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Vary":{"$ref":"#/components/headers/VaryPreview"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedirectPreview"},"example":{"status":302,"location":"https://aominuto.pt"}}}},"302":{"description":"Redirect to the project's live site.","headers":{"Location":{"description":"Live site URL.","schema":{"type":"string","format":"uri"},"example":"https://aominuto.pt"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Vary":{"$ref":"#/components/headers/VaryPreview"}}},"304":{"$ref":"#/components/responses/NotModified"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/experience":{"get":{"operationId":"listExperience","summary":"List work experience","description":"Employers, most recent first, each with its roles.","tags":["Career"],"parameters":[{"$ref":"#/components/parameters/lang"}],"responses":{"200":{"description":"Work experience.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Content-Language":{"$ref":"#/components/headers/ContentLanguage"}},"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Experience"}},"example":[{"id":"momentum-one","company":"Momentum One","location":"Remote / Lisbon","roles":[{"id":"full-stack-developer","title":"Full Stack Developer","start":"2022-04","end":"2025-12","period":"April 2022 - December 2025","internship":false,"highlights":["Owned an AI-powered moderation SaaS (LLM integration) for Discord, Telegram, and Slack communities, from early-stage build through production, with full technical autonomy.","Built React/Next.js dashboards for rule configuration and user management, focused on performance and UX.","Designed REST APIs and webhooks in Node.js/Hono, integrating Discord, Telegram, Slack, and Stripe.","Implemented JWT/OAuth authentication, authorization, and session management.","Modeled and maintained Cloudflare D1 (SQLite) and MongoDB databases.","Built serverless architectures on Cloudflare Workers for low latency, scalability, and cost control.","Integrated Stripe payments and Strapi CMS into e-commerce and subscription flows.","Set up Sentry and PostHog for production monitoring, logging, and analytics.","Contributed to Web3 on-chain event projects: event tracking, wallet integration, notification routing."]},{"id":"b2b-support-community-administrator","title":"(Internship) B2B Support / Community Administrator","start":"2021-11","end":"2022-03","period":"November 2021 - March 2022","internship":true,"highlights":["B2B support and Discord community administration for technology projects.","Assisted with development and testing of moderation bots.","Created technical documentation and internal processes.","Automated recurring tasks for community onboarding."]}]},{"id":"tactis","company":"Tactis","location":"Porto","roles":[{"id":"full-stack-developer","title":"(Internship) Full Stack Developer","start":"2020-03","end":"2020-07","period":"March 2020 - July 2020","internship":true,"highlights":["Developed web features for management systems.","Implemented Angular interfaces integrated with an existing Java API.","Collaborated on new endpoint creation and SQL database integration.","Supported interface design and functional flow design."]}]}]}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/education":{"get":{"operationId":"listEducation","summary":"List education","description":"Degrees and courses, most recent first.","tags":["Career"],"parameters":[{"$ref":"#/components/parameters/lang"}],"responses":{"200":{"description":"Education.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Content-Language":{"$ref":"#/components/headers/ContentLanguage"}},"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Education"}},"example":[{"id":"isep","school":"ISEP - Instituto Superior de Engenharia do Porto","degree":"Bachelor's Degree","field":"Informatics Engineering","start":"2026-09","end":null,"period":"September 2026 - Present","description":null,"url":"https://www.isep.ipp.pt"},{"id":"ironhack","school":"Ironhack","degree":"Bootcamp","field":"Full Stack Web Development","start":"2022-05","end":"2022-11","period":"May 2022 - November 2022","description":"Intensive full-stack web development training focused on Node.js and React. Built complete applications including REST API design, authentication and authorization, and NoSQL database integration (MongoDB) with data modeling and CRUD operations. Hands-on work on individual and team projects covering the full frontend-to-backend flow.","url":"https://ironhack.com"},{"id":"escola-profissional-do-infante","school":"Escola Profissional do Infante","degree":"Professional Course","field":"Computer Systems Programming and Management Technician","start":"2017-09","end":"2020-07","period":"September 2017 - July 2020","description":null,"url":"https://epinfante.pt"}]}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/skills":{"get":{"operationId":"listSkills","summary":"List skills","description":"Skills grouped by area.","tags":["Career"],"parameters":[{"$ref":"#/components/parameters/lang"}],"responses":{"200":{"description":"Skill groups.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"},"Content-Language":{"$ref":"#/components/headers/ContentLanguage"}},"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillGroup"}},"example":[{"id":"languages","name":"Languages","items":["JavaScript","TypeScript","SQL"]},{"id":"frontend","name":"Frontend","items":["React","Next.js","Tailwind CSS"]},{"id":"backend","name":"Backend","items":["Node.js","Express","Hono","REST APIs","Webhooks","JWT / OAuth","Microservices"]},{"id":"aiLlms","name":"AI / LLMs","items":["LLM integration","Automated moderation","Data pipelines"]},{"id":"integrations","name":"Integrations","items":["Stripe","Strapi","Discord","Telegram","Sentry","PostHog"]},{"id":"cloudDevops","name":"Cloud & DevOps","items":["Cloudflare Workers","Cloudflare Pages","Cloudflare Email Routing","Git"]},{"id":"databases","name":"Databases","items":["Cloudflare D1 (SQLite)","MongoDB"]}]}}},"304":{"$ref":"#/components/responses/NotModified"},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/openapi.json":{"get":{"operationId":"getOpenApi","summary":"OpenAPI document","description":"This document, generated from the routes the API serves.","tags":["Meta"],"responses":{"200":{"description":"OpenAPI 3.1 document.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"type":"object","description":"OpenAPI 3.1 document."}}}},"304":{"$ref":"#/components/responses/NotModified"}}}}},"components":{"schemas":{"Profile":{"type":"object","additionalProperties":false,"required":["role","years","location","backend","frontend","data","cloud","languages"],"properties":{"role":{"type":"string"},"years":{"type":"integer","description":"Years of professional experience."},"location":{"type":"string"},"backend":{"type":"array","items":{"type":"string"}},"frontend":{"type":"array","items":{"type":"string"}},"data":{"type":"array","items":{"type":"string"}},"cloud":{"type":"array","items":{"type":"string"}},"languages":{"type":"array","items":{"type":"string"},"description":"Spoken languages, as ISO 639-1 codes."}}},"Project":{"type":"object","additionalProperties":false,"required":["slug","name","description","stack","featured","url","repo"],"properties":{"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"stack":{"type":"array","items":{"type":"string"}},"featured":{"type":"boolean","description":"Shown on the home page."},"url":{"type":"string","format":"uri","description":"Live site."},"repo":{"type":["string","null"],"format":"uri","description":"Public source repository, if any."}}},"ExperienceRole":{"type":"object","additionalProperties":false,"required":["id","title","start","end","period","internship","highlights"],"properties":{"id":{"type":"string"},"title":{"type":"string"},"start":{"type":"string","pattern":"^[0-9]{4}-[0-9]{2}$","description":"Month in YYYY-MM form."},"end":{"type":["string","null"],"pattern":"^[0-9]{4}-[0-9]{2}$","description":"Month in YYYY-MM form."},"period":{"type":"string","description":"Localised period label."},"internship":{"type":"boolean"},"highlights":{"type":"array","items":{"type":"string"}}}},"Experience":{"type":"object","additionalProperties":false,"required":["id","company","location","roles"],"properties":{"id":{"type":"string"},"company":{"type":"string"},"location":{"type":"string"},"roles":{"type":"array","items":{"$ref":"#/components/schemas/ExperienceRole"}}}},"Education":{"type":"object","additionalProperties":false,"required":["id","school","degree","field","start","end","period","description","url"],"properties":{"id":{"type":"string"},"school":{"type":"string"},"degree":{"type":"string"},"field":{"type":"string"},"start":{"type":"string","pattern":"^[0-9]{4}-[0-9]{2}$","description":"Month in YYYY-MM form."},"end":{"type":["string","null"],"pattern":"^[0-9]{4}-[0-9]{2}$","description":"Month in YYYY-MM form."},"period":{"type":"string","description":"Localised period label."},"description":{"type":["string","null"]},"url":{"type":"string","format":"uri"}}},"SkillGroup":{"type":"object","additionalProperties":false,"required":["id","name","items"],"properties":{"id":{"type":"string","enum":["languages","frontend","backend","aiLlms","integrations","cloudDevops","databases"]},"name":{"type":"string","description":"Localised group name."},"items":{"type":"array","items":{"type":"string"}}}},"RedirectPreview":{"type":"object","additionalProperties":false,"required":["status","location"],"properties":{"status":{"type":"integer","const":302},"location":{"type":"string","format":"uri"}},"description":"Returned instead of the redirect when the request asks for a preview."},"Problem":{"type":"object","required":["type","title","status"],"properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"description":"RFC 9457 problem details."}},"parameters":{"lang":{"name":"lang","in":"query","required":false,"description":"Language of the human-readable strings.","schema":{"type":"string","enum":["en","pt"],"default":"en"}}},"headers":{"ETag":{"description":"Hash of the body. Send it back in If-None-Match.","schema":{"type":"string"}},"CacheControl":{"description":"Caching policy.","schema":{"type":"string"},"example":"public, max-age=300"},"ContentLanguage":{"description":"Language of the strings in the body.","schema":{"type":"string","enum":["en","pt"]}},"VaryPreview":{"description":"The response depends on the X-Preview header.","schema":{"type":"string"},"example":"X-Preview"}},"responses":{"NotModified":{"description":"The ETag in If-None-Match still matches."},"BadRequest":{"description":"Invalid query parameter.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"about:blank","title":"Bad Request","status":400,"detail":"Unsupported lang \"fr\". Use one of: en, pt.","instance":"/v1/projects"}}}},"NotFound":{"description":"No such resource.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"about:blank","title":"Not Found","status":404,"detail":"No project with slug \"unknown\".","instance":"/v1/projects/unknown"}}}}}}}