AI Agent API Design: Registration, Heartbeats, and Rate Limits
Learn how to design and implement a production-ready REST API for AI agents with registration, heartbeat monitoring, and rate limiting using Next.js and Convex.
When you're building AI agents that operate autonomously, you need a solid API layer to manage their lifecycle — registration, health monitoring, and abuse protection. Without these foundations, you'll end up with agents that silently fail, get rate-limited by upstream providers, or run up costs without any accountability.
In this tutorial, you'll design and implement a complete REST API for managing AI agents using Next.js and Convex. If you're new to building agents, start with our complete guide to building AI agents in 2026 for the bigger picture.
Prerequisites
- Next.js 16 (App Router)
- TypeScript
- Basic REST API knowledge
- Convex project for persistence
What you'll build
You'll design and implement a REST API for AI agents that includes:
- Agent registration and verification
- Heartbeat endpoints for uptime tracking
- Rate limiting and abuse protection
- Best-practice response formats
1) Define your API contracts
We'll build these endpoints:
POST /api/agents/registerPOST /api/agents/verifyPOST /api/agents/heartbeatPOST /api/agents/events
Each endpoint returns a JSON envelope:
export type ApiResponse<T> = {
ok: boolean;
data?: T;
error?: { code: string; message: string };
};
This consistent response shape makes it easy for agent clients to parse results — whether they're built with the Claude API or OpenAI. Every response is predictable, which matters when your consumers are autonomous agents, not humans reading error pages.
2) Create Convex tables
Edit convex/schema.ts:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
agents: defineTable({
name: v.string(),
apiKey: v.string(),
status: v.union(v.literal("pending"), v.literal("active")),
createdAt: v.number(),
}).index("by_key", ["apiKey"]),
heartbeats: defineTable({
agentId: v.id("agents"),
lastSeen: v.number(),
status: v.string(),
}).index("by_agent", ["agentId"]),
});
The by_key index on apiKey is critical — without it, every verification call would do a full table scan. Convex indexes make lookups O(1) instead of O(n).
3) Implement registration
Create convex/agents.ts:
import { mutation, query } from "convex/server";
import { v } from "convex/values";
import crypto from "crypto";
export const register = mutation({
args: { name: v.string() },
handler: async (ctx, args) => {
const apiKey = crypto.randomBytes(24).toString("hex");
const id = await ctx.db.insert("agents", {
name: args.name,
apiKey,
status: "pending",
createdAt: Date.now(),
});
return { agentId: id, apiKey };
},
});
export const verify = mutation({
args: { apiKey: v.string() },
handler: async (ctx, args) => {
const agent = await ctx.db
.query("agents")
.withIndex("by_key", (q) => q.eq("apiKey", args.apiKey))
.first();
if (!agent) throw new Error("Invalid key");
await ctx.db.patch(agent._id, { status: "active" });
return { agentId: agent._id, status: "active" };
},
});
The two-step register → verify flow prevents agents from immediately making API calls. This is essential for agent authentication and security — you want a human (or a trusted system) to approve each agent before it can act.
4) Add the API routes
Create app/api/agents/register/route.ts:
import { NextRequest } from "next/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
const convex = new ConvexHttpClient(process.env.CONVEX_URL!);
export async function POST(req: NextRequest) {
const { name } = await req.json();
const data = await convex.mutation(api.agents.register, { name });
return Response.json({ ok: true, data });
}
Create app/api/agents/verify/route.ts:
import { NextRequest } from "next/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
const convex = new ConvexHttpClient(process.env.CONVEX_URL!);
export async function POST(req: NextRequest) {
const { apiKey } = await req.json();
try {
const data = await convex.mutation(api.agents.verify, { apiKey });
return Response.json({ ok: true, data });
} catch (err: any) {
return Response.json({ ok: false, error: { code: "INVALID_KEY", message: err.message } });
}
}
5) Add heartbeat tracking
Heartbeats are how you know your agents are alive. Without them, an agent could crash silently and you'd never know until a user complains. The pattern is simple: agents POST their status every N seconds, and you track when they were last seen.
Update convex/agents.ts:
export const heartbeat = mutation({
args: { apiKey: v.string(), status: v.string() },
handler: async (ctx, args) => {
const agent = await ctx.db
.query("agents")
.withIndex("by_key", (q) => q.eq("apiKey", args.apiKey))
.first();
if (!agent || agent.status !== "active") {
throw new Error("Unauthorized agent");
}
const existing = await ctx.db
.query("heartbeats")
.withIndex("by_agent", (q) => q.eq("agentId", agent._id))
.first();
if (existing) {
await ctx.db.patch(existing._id, { lastSeen: Date.now(), status: args.status });
} else {
await ctx.db.insert("heartbeats", {
agentId: agent._id,
lastSeen: Date.now(),
status: args.status,
});
}
return { ok: true };
},
});
Create app/api/agents/heartbeat/route.ts:
import { NextRequest } from "next/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
const convex = new ConvexHttpClient(process.env.CONVEX_URL!);
export async function POST(req: NextRequest) {
const { apiKey, status } = await req.json();
try {
const data = await convex.mutation(api.agents.heartbeat, { apiKey, status });
return Response.json({ ok: true, data });
} catch (err: any) {
return Response.json({ ok: false, error: { code: "UNAUTHORIZED", message: err.message } });
}
}
6) Add rate limiting
Rate limiting is non-negotiable for agent APIs. Agents can run in loops, retry aggressively, or simply malfunction — and without rate limits, a single runaway agent can bring down your entire backend.
Create lib/rateLimit.ts:
const windowMs = 60_000;
const maxRequests = 60;
const memoryStore = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(key: string) {
const now = Date.now();
const current = memoryStore.get(key);
if (!current || current.resetAt < now) {
memoryStore.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true };
}
if (current.count >= maxRequests) return { ok: false };
current.count += 1;
return { ok: true };
}
Use it in your route:
import { rateLimit } from "@/lib/rateLimit";
export async function POST(req: NextRequest) {
const key = req.headers.get("x-forwarded-for") ?? "anon";
const limit = rateLimit(key);
if (!limit.ok) {
return Response.json({ ok: false, error: { code: "RATE_LIMIT", message: "Too many requests" } }, { status: 429 });
}
// ...rest of handler
}
For production, consider upgrading from an in-memory store to Redis or a distributed rate limiter. The in-memory approach won't survive serverless cold starts on Vercel, so you'll want persistence for real deployments.
7) Add event ingestion endpoint
Agents can post events (tool usage, errors, etc.) for monitoring. This is the foundation for testing and evaluating your agents in production.
// app/api/agents/events/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { event, payload } = await req.json();
// In production, persist to Convex or a logging service
console.log(`[agent-event] ${event}`, payload);
return Response.json({ ok: true, data: { event, payload } });
}
8) Input validation with Zod
Before trusting any data from agents, validate it. Zod makes this straightforward:
import { z } from "zod";
const registerSchema = z.object({
name: z.string().min(1).max(100),
});
const heartbeatSchema = z.object({
apiKey: z.string().length(48),
status: z.enum(["healthy", "degraded", "error"]),
});
// Use in your route:
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return Response.json({
ok: false,
error: { code: "VALIDATION_ERROR", message: parsed.error.message },
}, { status: 400 });
}
// ...proceed with parsed.data
}
9) Scaling to multi-agent systems
Once you have more than one agent, you'll need to think about how they coordinate. Each agent gets its own API key and heartbeat, but they might share resources or need to communicate. For patterns on managing multiple agents through a single API layer, see our guide on multi-agent orchestration patterns.
10) Troubleshooting tips
- 401 unauthorized: Ensure agents are verified before heartbeat.
- Rate limit too strict: Increase
maxRequestsor use Redis for production. - API keys leaked: Rotate keys and invalidate old ones in the database.
- Missing headers: Check proxies for
x-forwarded-for. - Convex schema errors: Run
npx convex devto push schema changes before testing. - CORS issues: If agents call from browsers, add appropriate CORS headers to your Next.js API routes.
Final thoughts
This API design gives you a safe, scalable way to register and manage AI agents. It's simple enough to build quickly yet strong enough to handle production-level traffic. The combination of registration, heartbeats, rate limiting, and input validation covers the essential lifecycle of any agent system.
From here, you can layer on more sophisticated features — webhook notifications when agents go offline, usage-based billing per API key, or audit logging for compliance. The foundation we've built today scales cleanly into all of those directions. I cover advanced API patterns for production agent systems in the AI Agent Masterclass.
Related reading
Enjoyed this guide?
Get more actionable AI insights, automation templates, and practical guides delivered to your inbox.
No spam. Unsubscribe anytime.
Ready to ship an AI product?
We build revenue-moving AI tools in focused agentic development cycles. 3 production apps shipped in a single day.