ScreenshotNeo

BlogHow-to

How to Build an API: A Beginner’s Guide for Developers

Build a secure, documented API from scratch with runnable Node.js code, testing, deployment guidance, and practical design choices.

By the ScreenshotNeo team1 October 20267 min read

Short answer: build an API by defining a resource and its HTTP contract, implementing a small set of routes, validating and authorizing requests, documenting the contract with OpenAPI, testing failures as well as success, then deploying with HTTPS and monitoring.

1. Define the resource and contract

Start with the use case, not the framework. Identify resources, relationships, accepted fields, response shapes, authentication, and error behavior. A design-first workflow uses OpenAPI as the blueprint for endpoints, data models, and authentication methods (Google Cloud API design guide).

Method Path Purpose Success
GET /api/todoitems List items 200
GET /api/todoitems/{id} Read one 200
POST /api/todoitems Create 201
PUT /api/todoitems/{id} Replace 200
DELETE /api/todoitems/{id} Remove 204

Microsoft uses this route shape in its Todo API tutorial (Minimal API tutorial).

2. Choose minimal routes or controllers

Minimal APIs are designed to create HTTP APIs with minimal dependencies. They fit a small service where routes and validation can stay together. Controller-based APIs add structure for larger projects with many models, filters, persistence concerns, and cross-cutting features.

Minimal routes Controllers
Few files and dependencies More conventions and organization
Good for a focused service Good for complex domains and teams
Fast first slice Easier shared filters and policies

3. Build a runnable Node.js API

This Express example uses memory instead of a database so the HTTP behavior is easy to see. Data disappears when the process restarts.

mkdir todo-api
cd todo-api
npm init -y
npm install express

Create server.js:

const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json({ limit: '32kb' }));
let items = [];

function fail(res, status, code, message) {
  return res.status(status).json({ error: { code, message } });
}
function validate(body, partial = false) {
  if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be an object';
  if (!partial && typeof body.title !== 'string') return 'title is required';
  if (body.title !== undefined && (typeof body.title !== 'string' || !body.title.trim())) return 'title must be non-empty';
  if (body.completed !== undefined && typeof body.completed !== 'boolean') return 'completed must be boolean';
  for (const key of Object.keys(body)) if (!['title', 'completed'].includes(key)) return `unknown field: ${key}`;
  return null;
}

app.get('/healthz', (req, res) => res.json({ status: 'ok' }));
app.get('/api/todoitems', (req, res) => {
  const value = req.query.completed;
  if (value !== undefined && !['true', 'false'].includes(value)) return fail(res, 400, 'invalid_query', 'completed must be true or false');
  res.json(value === undefined ? items : items.filter(x => x.completed === (value === 'true')));
});
app.get('/api/todoitems/:id', (req, res) => {
  const item = items.find(x => x.id === req.params.id);
  if (!item) return fail(res, 404, 'not_found', 'Todo item not found');
  res.json(item);
});
app.post('/api/todoitems', (req, res) => {
  const problem = validate(req.body);
  if (problem) return fail(res, 400, 'invalid_body', problem);
  const now = new Date().toISOString();
  const item = { id: crypto.randomUUID(), title: req.body.title.trim(), completed: req.body.completed ?? false, createdAt: now, updatedAt: now };
  items.push(item);
  res.status(201).location(`/api/todoitems/${item.id}`).json(item);
});
app.put('/api/todoitems/:id', (req, res) => {
  const index = items.findIndex(x => x.id === req.params.id);
  if (index < 0) return fail(res, 404, 'not_found', 'Todo item not found');
  const problem = validate(req.body);
  if (problem) return fail(res, 400, 'invalid_body', problem);
  items[index] = { ...items[index], title: req.body.title.trim(), completed: req.body.completed ?? false, updatedAt: new Date().toISOString() };
  res.json(items[index]);
});
app.delete('/api/todoitems/:id', (req, res) => {
  const index = items.findIndex(x => x.id === req.params.id);
  if (index < 0) return fail(res, 404, 'not_found', 'Todo item not found');
  items.splice(index, 1);
  res.status(204).end();
});
app.use((req, res) => fail(res, 404, 'route_not_found', 'No route matches this method and path'));
app.use((err, req, res, next) => {
  if (err instanceof SyntaxError && err.status === 400) return fail(res, 400, 'invalid_json', 'Request body is not valid JSON');
  console.error(err); fail(res, 500, 'internal_error', 'Unexpected server error');
});
app.listen(process.env.PORT || 3000, () => console.log('Todo API listening'));
node server.js

4. Test every route

cURL

curl -i -X POST http://localhost:3000/api/todoitems -H 'Content-Type: application/json' -d '{"title":"Read the API contract"}'
curl -i http://localhost:3000/api/todoitems
curl -i 'http://localhost:3000/api/todoitems?completed=false'
curl -i http://localhost:3000/api/todoitems/ITEM_ID
curl -i -X PUT http://localhost:3000/api/todoitems/ITEM_ID -H 'Content-Type: application/json' -d '{"title":"Review the contract","completed":true}'
curl -i -X DELETE http://localhost:3000/api/todoitems/ITEM_ID

Python

import requests
base = 'http://localhost:3000'
r = requests.post(f'{base}/api/todoitems', json={'title': 'Ship the first slice'}, timeout=10)
r.raise_for_status()
print(r.json())
r = requests.get(f'{base}/api/todoitems', params={'completed': 'false'}, timeout=10)
r.raise_for_status()
print(r.json())

Node.js

const base = 'http://localhost:3000';
const res = await fetch(`${base}/api/todoitems`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Add authentication' }) });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());

Test successful reads and writes, malformed JSON, missing fields, unknown fields, invalid query values, missing IDs, unsupported methods, authentication failures, and content types.

5. Document with OpenAPI

Keep an OpenAPI file in source control and update it with every route change. It can drive validation, generated clients, contract tests, and interactive documentation.

openapi: 3.0.3
info:
  title: Todo API
  version: 1.0.0
paths:
  /api/todoitems:
    get:
      responses:
        '200': { description: List of items }
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TodoInput'
      responses:
        '201': { description: Created }
components:
  schemas:
    TodoInput:
      type: object
      required: [title]
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1 }
        completed: { type: boolean }

Limit Swagger UI to suitable environments. Microsoft warns that enabling Swagger in production could expose sensitive details about API structure and implementation (controller API tutorial).

6. Validation, errors, and compatibility

  • Use 400 for malformed input, 401 for missing or invalid credentials, 403 for insufficient permission, 404 for missing resources, 409 for state conflicts, and 429 for rate limits.
  • Keep one error shape with a stable machine code, message, and optional field details.
  • Allow-list writable fields to prevent over-posting. Never trust client-supplied ownership, roles, prices, or timestamps.
  • Use ISO 8601 timestamps, explicit nullability, documented enums, pagination, and stable sorting.
  • Prefer additive changes. Version only when a breaking change cannot be avoided, and publish a migration path.

7. Secure before release

  1. Require HTTPS.
  2. Verify authentication on every protected route.
  3. Authorize the requested action against the authenticated user and resource owner.
  4. Limit request size, validate types and lengths, and set timeouts.
  5. Store secrets outside source control and redact tokens from logs.
  6. Apply rate limits and audit security events.
  7. Configure CORS for known origins.
  8. Hide stack traces and protect interactive documentation.

8. Testing strategy

Use unit tests for validation and authorization, integration tests for routes and persistence, contract tests against OpenAPI, load tests for latency and throughput, and security tests for access control and secret leakage. Postman provides a general API testing workflow (Postman API basics). SoapUI groups API practice into functional, load, security, automation, and mocking work.

9. Deploy and observe

Build an immutable artifact, configure through environment variables, run migrations as a controlled step, and terminate TLS at a managed edge or reverse proxy. Keep /healthz lightweight and use a separate readiness check for dependencies. Monitor request count, error rate, latency, saturation, and usage. Google Cloud recommends monitoring errors, latency, and usage after deployment. See Microsoft’s Azure deployment guidance for a concrete hosting workflow.

10. Performance, reliability, and cost

  • Paginate collections, index filter columns, avoid N+1 queries, and select only needed fields.
  • Reuse connections and stream large responses where appropriate.
  • Use bounded exponential retries only for idempotent operations.
  • Add idempotency keys to retried writes.
  • Use transactions for related writes and optimistic concurrency for competing updates.
  • Measure compute, database, egress, logs, and third-party calls per request.

11. Troubleshooting

Symptom Cause Fix
404 on a valid URL Wrong prefix, path, or method Compare with the OpenAPI path and route logs.
400 invalid JSON Malformed body or missing content type Send valid JSON with Content-Type: application/json.
Empty request body Parser missing or registered late Register express.json() before routes.
401/403 Bad credentials or insufficient scope Check token expiry, audience, scope, and ownership.
Browser CORS error Origin or preflight rejected Allow the exact origin and OPTIONS headers.
Duplicate writes Retried non-idempotent request Use an idempotency key and persist its result.
Slow list endpoint Unbounded query or missing index Add pagination, stable sorting, and indexes.

12. Or skip the browser setup

For a screenshot API, you can operate browser workers yourself or call ScreenshotNeo. Cookie and consent banners are accepted and 60+ known consent platforms, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed; the response identifies the result with X-Page-Verdict and X-Billed headers.

The ScreenshotNeo documentation covers full-page and element capture, device presets, custom viewports, retina scale, PDFs, custom CSS and JavaScript, waits, request blocking, headers, cookies, geolocation, caching, signed links, async webhooks, bulk capture, and usage reporting.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

13. FAQ

How many endpoints should the first release have?

One resource and the smallest complete set of reads and writes is enough.

Do I need a database?

No. Memory is useful for learning; use a database when data must survive restarts or be shared across instances.

When should I version?

Keep compatible additions in the current version. Create a new version for breaking changes and provide a migration path.

Is OpenAPI only documentation?

No. It can also drive validation, generated clients, contract tests, and breaking-change review.