Project: API and Integrations
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:
- A public REST API that lets other applications create, read, update, and delete tasks and projects programmatically
- Webhooks that notify external services when events happen inside FlowTask
- 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
lastUsedAttimestamp 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.