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

POST /api/generate check quota No Yes POST /api/webhooks Client Route Handler Quota OK? 429 quota exceeded POST /v1/predictions + webhook Save to DB status=queued Return generation_id Skytells Webhook Handler Download output Upload to Blob storage Update DB status=succeeded

Setup

npx create-next-app@latest ai-image-studio --typescript --tailwind --app
cd ai-image-studio
npm install @prisma/client @vercel/blob
npx prisma init

Database 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 push

Generate 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 DB
  • POST /api/webhooks — verifies signature, downloads output, persists to Blob, updates DB
  • GET /api/predictions/:id — frontend polling endpoint

Next: build the React frontend that brings this all together.

On this page