Skip to content
Lesson 17 of 22

Project: API and Integrations

15 min read

Project Overview

FlowTask is a working SaaS app, but right now it is a walled garden. No other application can read or write data to it. No external service gets notified when things happen. That limits its usefulness dramatically.

In this lesson, we are extending FlowTask with three capabilities that transform it from a standalone app into a platform:

  1. A public REST API that lets other applications create, read, update, and delete tasks and projects programmatically
  2. Webhooks that notify external services when events happen inside FlowTask
  3. Integrations with real-world services like Stripe for payments and Resend for transactional email

By the end of this lesson, FlowTask will have API key authentication, rate limiting, auto-generated documentation, webhook delivery with retry logic, payment processing, and email notifications. These are the building blocks that turn a side project into a real business.

Designing a Public API

A good API is predictable, consistent, and well-documented. Before writing any code, let us plan the design.

RESTful Conventions

Our API will follow REST conventions:

  • Nouns, not verbs in URLs: /api/v1/tasks, not /api/v1/getTasks
  • HTTP methods express the action: GET reads, POST creates, PUT updates, DELETE removes
  • Versioning with a path prefix: /api/v1/ so we can release v2 without breaking existing integrations
  • Consistent response format for every endpoint

Response Format

Every response follows the same shape:

{
  "data": { ... },
  "meta": {
    "page": 1,
    "perPage": 20,
    "total": 47
  },
  "error": null
}

For errors:

{
  "data": null,
  "meta": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Title is required",
    "details": [
      { "field": "title", "message": "This field is required" }
    ]
  }
}

Endpoint Plan

Here is the complete endpoint list:

| Method | Endpoint | Description | |--------|----------|-------------| | GET | /api/v1/tasks | List tasks with pagination and filtering | | GET | /api/v1/tasks/:id | Get a single task | | POST | /api/v1/tasks | Create a task | | PUT | /api/v1/tasks/:id | Update a task | | DELETE | /api/v1/tasks/:id | Delete a task | | GET | /api/v1/projects | List projects | | GET | /api/v1/projects/:id | Get a single project | | POST | /api/v1/projects | Create a project | | PUT | /api/v1/projects/:id | Update a project | | DELETE | /api/v1/projects/:id | Delete a project |

Building API Endpoints

Let us build the task endpoints. The project endpoints follow the same pattern.

The Prompt

Build the public API for FlowTask. Create these endpoint files under
app/api/v1/:

1. tasks/route.ts — handles GET (list) and POST (create)
2. tasks/[id]/route.ts — handles GET (single), PUT (update), DELETE

Requirements:
- Authenticate using an API key in the Authorization header (Bearer scheme)
- Validate request bodies using zod schemas
- Return consistent JSON responses with data/meta/error shape
- Support pagination on list endpoints: ?page=1&perPage=20
- Support filtering tasks by status, priority, and projectId
- Return proper HTTP status codes: 200 OK, 201 Created, 204 No Content,
  400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Internal Server Error
- Include TypeScript types for all request/response shapes

Create a shared utility function for the response format.

GET /api/v1/tasks

The list endpoint supports pagination and filtering:

// app/api/v1/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authenticateApiKey } from "@/lib/api-auth";
import { apiResponse, apiError } from "@/lib/api-response";

export async function GET(request: NextRequest) {
  const user = await authenticateApiKey(request);
  if (!user) return apiError("Unauthorized", 401);

  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get("page") || "1");
  const perPage = parseInt(searchParams.get("perPage") || "20");
  const status = searchParams.get("status");
  const priority = searchParams.get("priority");
  const projectId = searchParams.get("projectId");

  const where = {
    project: { userId: user.id },
    ...(status && { status }),
    ...(priority && { priority }),
    ...(projectId && { projectId }),
  };

  const [tasks, total] = await Promise.all([
    prisma.task.findMany({
      where,
      skip: (page - 1) * perPage,
      take: perPage,
      orderBy: { createdAt: "desc" },
      include: { project: { select: { id: true, name: true } } },
    }),
    prisma.task.count({ where }),
  ]);

  return apiResponse(tasks, { page, perPage, total });
}

POST /api/v1/tasks

The create endpoint validates the request body:

export async function POST(request: NextRequest) {
  const user = await authenticateApiKey(request);
  if (!user) return apiError("Unauthorized", 401);

  const body = await request.json();
  const parsed = createTaskSchema.safeParse(body);
  if (!parsed.success) {
    return apiError("Validation failed", 400, parsed.error.issues);
  }

  const { title, description, status, priority, dueDate, projectId } = parsed.data;

  // Verify the project belongs to the user
  const project = await prisma.project.findFirst({
    where: { id: projectId, userId: user.id },
  });
  if (!project) return apiError("Project not found", 404);

  const task = await prisma.task.create({
    data: { title, description, status, priority, dueDate, projectId },
  });

  return apiResponse(task, null, 201);
}

Proper HTTP Status Codes

Each endpoint returns the appropriate status code:

  • 200 --- Successful GET or PUT
  • 201 --- Successful POST (resource created)
  • 204 --- Successful DELETE (no content to return)
  • 400 --- Bad request (validation errors, malformed JSON)
  • 401 --- Unauthorized (missing or invalid API key)
  • 404 --- Resource not found
  • 429 --- Rate limit exceeded
  • 500 --- Internal server error

These conventions make your API predictable. Any developer integrating with FlowTask knows exactly what to expect.

API Authentication

The web app uses session-based auth via NextAuth. The API needs a different mechanism: API keys.

The Prompt

Implement API key authentication for FlowTask:

1. Add an ApiKey model to Prisma: id, key (unique, indexed), name,
   userId (foreign key), lastUsedAt, createdAt. The key should be a
   random 32-character hex string prefixed with "ft_".

2. Create a settings page section for API keys: list existing keys,
   create new key (show once then mask), delete key.

3. Create an authenticateApiKey middleware function that:
   - Extracts the key from the Authorization: Bearer header
   - Looks up the key in the database
   - Updates lastUsedAt
   - Returns the associated user or null

4. Run the migration.

Key Generation

API keys should be cryptographically random:

import { randomBytes } from "crypto";

function generateApiKey(): string {
  return `ft_${randomBytes(32).toString("hex")}`;
}

The ft_ prefix helps users identify which service a key belongs to. This is a small but thoughtful detail that makes the developer experience better.

Security Considerations

When the AI generates the API key creation flow, verify these security practices:

  • Keys are hashed before storage (or at minimum, the full key is shown only once at creation time)
  • Keys can be revoked individually
  • Each key has a name so users can identify which integration uses which key
  • The lastUsedAt timestamp helps users identify inactive keys to revoke

Rate Limiting

Without rate limiting, a single API consumer could overwhelm your server. Rate limiting protects your infrastructure and ensures fair usage.

The Prompt

Add rate limiting to the FlowTask API:

1. Implement a sliding window rate limiter using an in-memory store (Map).
   For production, note that this should use Redis.

2. Rate limits:
   - Default: 100 requests per minute per API key
   - Create/update/delete endpoints: 30 requests per minute per API key

3. Add rate limit headers to every API response:
   - X-RateLimit-Limit: the maximum requests allowed
   - X-RateLimit-Remaining: requests left in the current window
   - X-RateLimit-Reset: Unix timestamp when the window resets

4. Return 429 Too Many Requests when the limit is exceeded, with a
   Retry-After header.

Create the rate limiter as middleware that wraps the API route handlers.

The Rate Limiter

The sliding window implementation tracks request timestamps:

// lib/rate-limiter.ts
const requests = new Map<string, number[]>();

export function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number = 60000
): { allowed: boolean; remaining: number; resetAt: number } {
  const now = Date.now();
  const windowStart = now - windowMs;

  const timestamps = (requests.get(key) || []).filter((t) => t > windowStart);
  timestamps.push(now);
  requests.set(key, timestamps);

  const remaining = Math.max(0, limit - timestamps.length);
  const resetAt = Math.ceil((windowStart + windowMs) / 1000);

  return {
    allowed: timestamps.length <= limit,
    remaining,
    resetAt,
  };
}

For production, replace the in-memory Map with Redis using ZRANGEBYSCORE for the sliding window. The prompt to make that switch:

Replace the in-memory rate limiter with Redis. Use the ioredis package.
Use a sorted set per API key with timestamps as scores. ZRANGEBYSCORE
to count requests in the current window. ZADD to record new requests.
ZREMRANGEBYSCORE to clean up expired entries.

Auto-Generated API Documentation

Good documentation is the difference between an API that people use and one they abandon. The best documentation is generated from your code so it never goes out of sync.

The Prompt

Generate API documentation for FlowTask:

1. Create an OpenAPI 3.0 specification (openapi.json) that describes all
   endpoints, request/response schemas, authentication, and error formats.

2. Create an interactive documentation page at /docs/api using Swagger UI
   or a similar renderer. The page should let developers try out API calls
   directly from the browser.

3. Include examples for every endpoint showing the request and response.

4. Document the authentication flow: how to get an API key, how to include
   it in requests.

5. Document rate limiting: headers, limits, and what to do when rate limited.

The OpenAPI spec defines your API in a machine-readable format:

{
  "openapi": "3.0.0",
  "info": {
    "title": "FlowTask API",
    "version": "1.0.0",
    "description": "Public API for managing FlowTask projects and tasks"
  },
  "servers": [
    { "url": "https://flowtask.app/api/v1" }
  ],
  "paths": {
    "/tasks": {
      "get": {
        "summary": "List tasks",
        "parameters": [
          { "name": "page", "in": "query", "schema": { "type": "integer" } },
          { "name": "status", "in": "query", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "List of tasks" }
        }
      }
    }
  }
}

Interactive docs let developers test your API without writing any code. They fill in parameters, click "Try it out," and see the response. This reduces friction enormously.

Webhooks: Sending Events

Webhooks let FlowTask notify external services when things happen. Instead of other apps polling your API every few seconds asking "did anything change?", you push updates to them in real time.

The Prompt

Implement outgoing webhooks for FlowTask:

1. Add a Webhook model to Prisma: id, url (string), secret (string for
   HMAC signing), events (string array of event types), userId, active
   (boolean), createdAt.

2. Add webhook management to the settings page: register a webhook URL,
   select which events to subscribe to, test webhook delivery, delete
   webhook.

3. Event types: task.created, task.updated, task.completed, task.deleted,
   project.created, project.deleted

4. Create a dispatchWebhook utility that:
   - Finds all active webhooks for the user subscribed to the event type
   - Sends a POST request to each webhook URL with the event payload
   - Signs the payload with HMAC-SHA256 using the webhook secret
   - Includes headers: X-FlowTask-Event, X-FlowTask-Signature,
     X-FlowTask-Delivery (unique ID)
   - Implements retry logic: retry 3 times with exponential backoff
     (1s, 5s, 25s) on failure

5. Call dispatchWebhook after every relevant Server Action and API mutation.

Run the migration.

Webhook Payload

Every webhook delivery includes a signed payload:

// lib/webhooks.ts
import crypto from "crypto";

interface WebhookPayload {
  event: string;
  data: Record<string, unknown>;
  timestamp: string;
  deliveryId: string;
}

function signPayload(payload: string, secret: string): string {
  return crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");
}

export async function dispatchWebhook(
  userId: string,
  event: string,
  data: Record<string, unknown>
) {
  const webhooks = await prisma.webhook.findMany({
    where: {
      userId,
      active: true,
      events: { has: event },
    },
  });

  const payload: WebhookPayload = {
    event,
    data,
    timestamp: new Date().toISOString(),
    deliveryId: crypto.randomUUID(),
  };

  const body = JSON.stringify(payload);

  for (const webhook of webhooks) {
    const signature = signPayload(body, webhook.secret);
    await deliverWithRetry(webhook.url, body, {
      "Content-Type": "application/json",
      "X-FlowTask-Event": event,
      "X-FlowTask-Signature": `sha256=${signature}`,
      "X-FlowTask-Delivery": payload.deliveryId,
    });
  }
}

Retry Logic

Network requests fail. Webhook delivery needs retry logic:

async function deliverWithRetry(
  url: string,
  body: string,
  headers: Record<string, string>,
  maxRetries: number = 3
) {
  const delays = [1000, 5000, 25000]; // Exponential backoff

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        method: "POST",
        headers,
        body,
        signal: AbortSignal.timeout(10000), // 10s timeout
      });

      if (response.ok) return;
      if (response.status < 500) return; // Don't retry client errors
    } catch {
      // Network error, will retry
    }

    if (attempt < maxRetries) {
      await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
    }
  }
}

Webhooks: Receiving Events

FlowTask sends webhooks, but it also needs to receive them from external services. The most common case: payment webhooks from Stripe.

Building a Webhook Listener

Create a webhook receiver endpoint at /api/webhooks/stripe that:

1. Reads the raw request body (don't parse JSON yet)
2. Verifies the Stripe signature using the stripe.webhooks.constructEvent
   method with the STRIPE_WEBHOOK_SECRET environment variable
3. Handles these event types:
   - checkout.session.completed — activate the user's subscription
   - customer.subscription.updated — update subscription status
   - customer.subscription.deleted — cancel the subscription
4. Returns 200 OK immediately, processes the event asynchronously
5. Logs all webhook events for debugging

Verifying Signatures

Never trust a webhook payload without verifying the signature. Anyone could send a POST to your webhook URL pretending to be Stripe:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new NextResponse("Invalid signature", { status: 400 });
  }

  // Signature verified — safe to process
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutCompleted(event.data.object);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionCanceled(event.data.object);
      break;
  }

  return new NextResponse("OK", { status: 200 });
}

The pattern is always the same: read raw body, verify signature, process event, return 200. Return 200 quickly --- if your handler takes too long, the webhook sender will think it failed and retry.

Integrating External APIs

Let us connect FlowTask to real services.

Stripe for Payments

Integrate Stripe payments into FlowTask:

1. Install the stripe package and @stripe/stripe-js for the frontend
2. Create a Subscription model: id, userId, stripeCustomerId,
   stripeSubscriptionId, plan (FREE, PRO, ENTERPRISE), status, currentPeriodEnd
3. Create a checkout API route that creates a Stripe Checkout Session
   for Pro or Enterprise plans
4. After successful payment (via webhook), update the user's subscription
5. Add a billing section to the settings page showing current plan, next
   billing date, and an "Upgrade" or "Manage Subscription" button
6. Use Stripe's customer portal for subscription management

Run the migration.

The checkout flow sends users to Stripe's hosted payment page, which handles all the sensitive credit card processing. You never touch card numbers:

export async function POST(request: NextRequest) {
  const user = await getCurrentUser();
  const { plan } = await request.json();

  const priceId =
    plan === "PRO"
      ? process.env.STRIPE_PRO_PRICE_ID
      : process.env.STRIPE_ENTERPRISE_PRICE_ID;

  const session = await stripe.checkout.sessions.create({
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: "subscription",
    success_url: `${process.env.NEXTAUTH_URL}/settings/billing?success=true`,
    cancel_url: `${process.env.NEXTAUTH_URL}/settings/billing?canceled=true`,
    metadata: { userId: user.id },
  });

  return NextResponse.json({ url: session.url });
}

Resend for Email Notifications

Integrate Resend for transactional emails:

1. Install the resend package
2. Create an email utility in lib/email.ts with functions for:
   - sendTaskAssignedEmail(task, assignee)
   - sendTaskCompletedEmail(task, projectOwner)
   - sendWeeklyDigestEmail(user, stats)
3. Create email templates as React components using @react-email/components
4. Call the email functions from the appropriate Server Actions and
   webhook handlers
5. Use the RESEND_API_KEY environment variable

The Resend integration is straightforward:

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendTaskAssignedEmail(
  task: Task,
  assignee: User
) {
  await resend.emails.send({
    from: "FlowTask <notifications@flowtask.app>",
    to: assignee.email,
    subject: `You've been assigned: ${task.title}`,
    react: TaskAssignedEmail({ task, assignee }),
  });
}

Testing Your API

Before shipping, test every endpoint thoroughly.

Using curl

# List tasks
curl -H "Authorization: Bearer ft_your_api_key" \
  http://localhost:3000/api/v1/tasks

# Create a task
curl -X POST \
  -H "Authorization: Bearer ft_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"title": "Test task", "projectId": "clx..."}' \
  http://localhost:3000/api/v1/tasks

# Update a task
curl -X PUT \
  -H "Authorization: Bearer ft_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"status": "DONE"}' \
  http://localhost:3000/api/v1/tasks/clx123

# Delete a task
curl -X DELETE \
  -H "Authorization: Bearer ft_your_api_key" \
  http://localhost:3000/api/v1/tasks/clx123

Writing Integration Tests

Write integration tests for the FlowTask API using vitest:

1. Test each endpoint: GET list, GET single, POST create, PUT update, DELETE
2. Test authentication: requests without API key return 401
3. Test validation: missing required fields return 400
4. Test authorization: users can only access their own data
5. Test pagination: verify page/perPage/total in meta
6. Test filtering: verify status and priority filters work

Use a test database (SQLite in-memory) and seed test data before each test.

Test the happy paths first, then the error cases. A well-tested API gives you confidence to iterate quickly.

import { describe, it, expect, beforeEach } from "vitest";

describe("GET /api/v1/tasks", () => {
  it("returns paginated tasks for authenticated user", async () => {
    const response = await fetch("/api/v1/tasks?page=1&perPage=10", {
      headers: { Authorization: `Bearer ${testApiKey}` },
    });

    expect(response.status).toBe(200);
    const json = await response.json();
    expect(json.data).toBeInstanceOf(Array);
    expect(json.meta.page).toBe(1);
    expect(json.meta.perPage).toBe(10);
  });

  it("returns 401 without API key", async () => {
    const response = await fetch("/api/v1/tasks");
    expect(response.status).toBe(401);
  });
});

What's Next

FlowTask now has a public API, webhook support, payment processing, and email notifications. It is a real platform that other developers can integrate with.

In the next lesson, we take things further by adding AI-powered features to FlowTask --- a chatbot, text summarization, semantic search with embeddings, and content generation. You are going to learn the difference between using AI to build apps and building apps that use AI. Both skills are essential.