Intermediate50 minModule 2 of 4
Backend API
Build the generation endpoint, webhook handler, database writes, and output storage for your AI Image Studio.
The generation flow
Setup
npx create-next-app@latest ai-image-studio --typescript --tailwind --app
cd ai-image-studio
npm install @prisma/client @vercel/blob
npx prisma initDatabase schema (Prisma)
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Generation {
id String @id
userId String
prompt String
model String @default("truefusion-pro")
status String @default("queued")
outputUrl String?
error String?
createdAt DateTime @default(now())
completedAt DateTime?
}npx prisma db pushGenerate endpoint
// app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const SKYTELLS_BASE = 'https://api.skytells.ai/v1';
const ALLOWED_MODELS = ['truefusion-pro', 'truefusion-edge', 'truefusion-2.0'] as const;
type AllowedModel = (typeof ALLOWED_MODELS)[number];
export async function POST(req: NextRequest) {
// 1. Parse & validate input
const { prompt, model = 'truefusion-pro', userId } = await req.json();
if (!prompt || typeof prompt !== 'string' || prompt.length > 500) {
return NextResponse.json({ error: 'Invalid prompt' }, { status: 400 });
}
if (!ALLOWED_MODELS.includes(model as AllowedModel)) {
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
}
// 2. Check user quota (simple: max 10 active generations)
const active = await prisma.generation.count({
where: { userId, status: { in: ['queued', 'processing'] } },
});
if (active >= 10) {
return NextResponse.json({ error: 'Quota exceeded' }, { status: 429 });
}
// 3. Create prediction on Skytells
const skytellsRes = await fetch(`${SKYTELLS_BASE}/predictions`, {
method: 'POST',
headers: {
'x-api-key': process.env.SKYTELLS_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
input: { prompt, width: 1024, height: 1024 },
webhook: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks`,
webhook_events_filter: ['completed'],
}),
});
if (!skytellsRes.ok) {
const err = await skytellsRes.json();
return NextResponse.json({ error: err.detail ?? 'Generation failed' }, { status: 502 });
}
const prediction = await skytellsRes.json();
// 4. Save to database
await prisma.generation.create({
data: {
id: prediction.id,
userId,
prompt,
model,
status: prediction.status,
},
});
return NextResponse.json({ id: prediction.id, status: prediction.status });
}Webhook handler
// app/api/webhooks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { put } from '@vercel/blob';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
const prisma = new PrismaClient();
function verifySignature(payload: string, signature: string) {
const expected = crypto
.createHmac('sha256', process.env.SKYTELLS_WEBHOOK_SECRET!)
.update(payload)
.digest('hex');
const received = signature.replace('sha256=', '');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get('x-skytells-signature') ?? '';
if (!verifySignature(rawBody, sig)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const prediction = JSON.parse(rawBody);
// Check idempotency
const existing = await prisma.generation.findUnique({ where: { id: prediction.id } });
if (!existing || existing.status === 'succeeded') {
return NextResponse.json({ ok: true }); // already processed
}
if (prediction.status === 'succeeded' && prediction.output?.[0]) {
// Download from Skytells CDN
const cdnResponse = await fetch(prediction.output[0]);
const imageBuffer = await cdnResponse.arrayBuffer();
// Upload to Vercel Blob (persistent storage)
const { url } = await put(`generations/${prediction.id}.png`, imageBuffer, {
access: 'public',
contentType: 'image/png',
});
await prisma.generation.update({
where: { id: prediction.id },
data: { status: 'succeeded', outputUrl: url, completedAt: new Date() },
});
} else if (prediction.status === 'failed') {
await prisma.generation.update({
where: { id: prediction.id },
data: { status: 'failed', error: prediction.error, completedAt: new Date() },
});
}
return NextResponse.json({ ok: true });
}Poll endpoint (frontend polling)
// app/api/predictions/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
const generation = await prisma.generation.findUnique({
where: { id: params.id },
select: { id: true, status: true, outputUrl: true, error: true },
});
if (!generation) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(generation);
}Environment variables
# .env.local
DATABASE_URL="postgresql://..."
SKYTELLS_API_KEY="sk-..."
SKYTELLS_WEBHOOK_SECRET="..."
BLOB_READ_WRITE_TOKEN="..." # Vercel Blob
NEXT_PUBLIC_APP_URL="http://localhost:3000"Summary
The backend is complete:
POST /api/generate— validates input, checks quota, creates prediction, saves to DBPOST /api/webhooks— verifies signature, downloads output, persists to Blob, updates DBGET /api/predictions/:id— frontend polling endpoint
Next: build the React frontend that brings this all together.