APIs — how apps talk to each other

APIs are one of the most important concepts in modern web development — and one of the least explained. Every app you've ever used talks to dozens of APIs. Here's exactly how they work.

What is an API?

API stands for Application Programming Interface. That's a mouthful. Here's what it actually means:

An API is a way for one program to ask another program for something. When your weather app shows the temperature, it didn't calculate that itself — it asked a weather API. When you log in with Google, your app talks to Google's API. When you pay with Stripe, your app talks to Stripe's API.

Think of it like a waiter at a restaurant. You (the client) don't go into the kitchen yourself. You tell the waiter (the API) what you want, the kitchen (the server) prepares it, and the waiter brings it back to you. The API is the defined interface between you and the kitchen.

Requests and responses

Every API interaction is a request followed by a response. The client asks, the server answers.

A simple API request in JavaScript
// Ask the GitHub API for info about a user
const response = await fetch('https://api.github.com/users/webgradeteam-png');
const data = await response.json();
console.log(data.name);       // "Webgrade"
console.log(data.public_repos); // 12

The fetch() function sends an HTTP request to the URL. The server responds with JSON data. response.json() parses that text into a JavaScript object you can work with.

JSON — the language of APIs

Most modern APIs send and receive data in JSON (JavaScript Object Notation). It looks like a JavaScript object but it's just text — every language can read it.

A typical JSON response from an API
{
  "id": 42,
  "name": "Alex Johnson",
  "email": "alex@example.com",
  "role": "admin",
  "active": true,
  "createdAt": "2024-01-15T10:30:00Z",
  "tags": ["developer", "beta-user"]
}

Keys are always strings in double quotes. Values can be strings, numbers, booleans (true/false), arrays [], nested objects {}, or null. No trailing commas. No comments.

HTTP methods

APIs use HTTP methods to indicate what operation you want to perform:

GET

Fetch data. Never changes anything. Safe to call multiple times. Used for reading users, posts, products.

POST

Create something new. Sends data in the request body. Creates a new user, new order, new post.

PUT

Replace a resource entirely. Send the full updated object.

PATCH

Update part of a resource. Only send the fields you're changing.

DELETE

Remove a resource. Usually no body needed — just the ID in the URL.

Status codes

Every response includes a status code telling you what happened:

200OK — request succeeded
201Created — new resource made
204No content — success, nothing to return
301Moved permanently
400Bad request — invalid data sent
401Unauthorised — login required
403Forbidden — wrong permissions
404Not found — URL doesn't exist
429Too many requests — rate limited
500Server error — backend crashed

Headers — metadata for every request

HTTP headers are key-value pairs sent alongside a request or response. They carry metadata — information about the request, authentication, content type, and more.

Common request headers
const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',     // "I'm sending JSON"
    'Authorization': `Bearer ${token}`,     // "Here's my auth token"
    'Accept': 'application/json',            // "Give me JSON back"
    'X-API-Key': process.env.API_KEY         // Some APIs use custom headers
  },
  body: JSON.stringify({ name: 'Alex', email: 'alex@example.com' })
});

Authentication

Most APIs require you to prove who you are. There are several common patterns:

Bearer token (most common)
headers: { 'Authorization': 'Bearer YOUR_TOKEN_HERE' }
API key in header
headers: { 'X-API-Key': 'YOUR_KEY_HERE' }
Basic auth (username + password, base64 encoded)
headers: { 'Authorization': 'Basic ' + btoa('username:password') }
Always store your auth credentials in environment variables, never hardcoded. See the API Keys guide for the full explanation.

Error handling

fetch() only throws an error if the network request fails entirely. A 404 or 500 response does NOT throw — you have to check the status code yourself.

Proper fetch error handling
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);

    // fetch doesn't throw on 4xx/5xx — check manually
    if (!res.ok) {
      throw new Error(`API error: ${res.status}`);
    }

    return await res.json();
  } catch (err) {
    console.error('Request failed:', err.message);
    throw err; // re-throw so the caller can handle it
  }
}

Rate limiting

APIs limit how many requests you can make to prevent abuse. When you exceed the limit, you get a 429 Too Many Requests response. Most APIs tell you your limit in response headers:

Common rate limit response headers
X-RateLimit-Limit: 100       # Total requests allowed per window
X-RateLimit-Remaining: 7     # Requests left in the current window
X-RateLimit-Reset: 1735689600 # Unix timestamp when the window resets
Retry-After: 60              # Seconds to wait before retrying

When you hit a rate limit: check the Retry-After header, wait that long, and try again. Never hammer an API in a loop after a 429 — you'll get blocked.

REST vs GraphQL

Most APIs are either REST or GraphQL. They solve the same problem differently:

REST — multiple endpoints, fixed data shapes
// Get user (returns ALL user fields)
GET /api/users/42

// Get user's posts (separate request)
GET /api/users/42/posts

// Get a specific post
GET /api/posts/8
GraphQL — one endpoint, you define the shape
// One request, get exactly what you need
POST /graphql
{
  query: `{
    user(id: 42) {
      name
      email
      posts(limit: 5) {
        title
        publishedAt
      }
    }
  }`
}

REST is simpler, more common, and easier to cache. GraphQL is more flexible but adds complexity. For most projects, REST is the right choice. Learn REST first.

Webhooks — APIs in reverse

A regular API call is pull: you ask, they respond. A webhook is push: they call you when something happens.

When a Stripe payment succeeds, Stripe doesn't wait for you to ask — it immediately sends an HTTP POST to a URL you configure. Your server receives that request and processes the event (fulfil the order, send a confirmation email, update the database).

Receiving a webhook in Express
app.post('/webhook/stripe', express.raw({type: 'application/json'}), (req, res) => {
  // Always verify the signature — proves it's really from Stripe
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook error: ${err.message}`);
  }

  if (event.type === 'payment_intent.succeeded') {
    // Fulfil the order
  }

  res.json({ received: true });
});
Always verify webhook signatures. Anyone can send a POST to your webhook URL. The signature check proves the request actually came from the service you expect.

Pagination

APIs never return unlimited data. When a list has more items than the page size, you get a page and a way to fetch the next one.

Cursor-based pagination (GitHub, Stripe style)
// First page
GET /api/posts?limit=20

// Response includes a cursor for the next page
{
  "data": [...],
  "next_cursor": "cur_abc123",
  "has_more": true
}

// Next page
GET /api/posts?limit=20&after=cur_abc123