API design: REST vs GraphQL vs Webhooks — quando usar cada padrão (com exemplos completos)

Guia prático de arquitetura de APIs: endpoints RESTful, queries GraphQL, webhooks e rate limiting. De básico a enterprise.

API bem projetada = facilita integrations, atrai desenvolvedores, escala sem refatoração.

API mal projetada = bugs constantes, performance ruim, impossível de manter.

Este artigo compara REST, GraphQL e Webhooks, com código completo em Next.js + TypeScript.

REST API (o padrão de facto)

REST (Representational State Transfer) = arquitetura baseada em recursos e verbos HTTP.

Princípios:

  • Recursos identificados por URLs (/api/users/123)
  • Verbos HTTP semânticos (GET, POST, PUT, DELETE)
  • Stateless (sem sessão no servidor)
  • Respostas cacheáveis

Exemplo completo (CRUD de usuários):

// app/api/users/route.ts
import { NextRequest } from 'next/server'
import { z } from 'zod'

// Schema de validação
const userSchema = z.object({
  name: z.string().min(3).max(100),
  email: z.string().email(),
  role: z.enum(['USER', 'ADMIN'])
})

// GET /api/users (listar)
export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)

  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')
  const role = searchParams.get('role') // Filtro opcional

  const where = role ? { role } : {}

  const [users, total] = await Promise.all([
    prisma.user.findMany({
      where,
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' }
    }),
    prisma.user.count({ where })
  ])

  return Response.json({
    data: users,
    meta: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  })
}

// POST /api/users (criar)
export async function POST(req: NextRequest) {
  const body = await req.json()

  // Validação
  const validation = userSchema.safeParse(body)
  if (!validation.success) {
    return Response.json(
      { error: 'Validation failed', details: validation.error.flatten() },
      { status: 400 }
    )
  }

  const { name, email, role } = validation.data

  // Verifica duplicação
  const existing = await prisma.user.findUnique({ where: { email } })
  if (existing) {
    return Response.json(
      { error: 'Email already exists' },
      { status: 409 }
    )
  }

  // Cria usuário
  const user = await prisma.user.create({
    data: { name, email, role }
  })

  return Response.json(user, { status: 201 })
}
// app/api/users/[id]/route.ts

// GET /api/users/123 (buscar)
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await prisma.user.findUnique({
    where: { id: params.id },
    include: {
      bookings: true, // Relações
      _count: {
        select: { bookings: true }
      }
    }
  })

  if (!user) {
    return Response.json(
      { error: 'User not found' },
      { status: 404 }
    )
  }

  return Response.json(user)
}

// PATCH /api/users/123 (atualizar parcial)
export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await req.json()

  // Validação parcial (campos opcionais)
  const updateSchema = userSchema.partial()
  const validation = updateSchema.safeParse(body)

  if (!validation.success) {
    return Response.json(
      { error: 'Validation failed', details: validation.error.flatten() },
      { status: 400 }
    )
  }

  const user = await prisma.user.update({
    where: { id: params.id },
    data: validation.data
  })

  return Response.json(user)
}

// DELETE /api/users/123 (deletar)
export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  await prisma.user.delete({
    where: { id: params.id }
  })

  return Response.json({ success: true }, { status: 204 })
}

Vantagens REST:

  • ✅ Simples e intuitivo
  • ✅ Cacheable (HTTP standard)
  • ✅ Tooling maduro (Postman, Insomnia)
  • ✅ Amplamente adotado

Desvantagens REST:

  • ❌ Over-fetching (traz dados não usados)
  • ❌ Under-fetching (precisa múltiplas requests)
  • ❌ Versionamento complexo (/api/v1, /api/v2)

GraphQL (flexibilidade de queries)

GraphQL = Client define exatamente quais dados quer.

Setup (Next.js + Apollo Server):

npm install @apollo/server graphql
// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { gql } from 'graphql-tag'

// Schema (tipos e queries)
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    role: Role!
    bookings: [Booking!]!
    bookingsCount: Int!
  }

  type Booking {
    id: ID!
    date: String!
    status: BookingStatus!
    user: User!
  }

  enum Role {
    USER
    ADMIN
  }

  enum BookingStatus {
    PENDING
    CONFIRMED
    COMPLETED
    CANCELED
  }

  type Query {
    users(page: Int, limit: Int, role: Role): UsersResponse!
    user(id: ID!): User
    bookings(userId: ID, status: BookingStatus): [Booking!]!
  }

  type Mutation {
    createUser(name: String!, email: String!, role: Role!): User!
    updateUser(id: ID!, name: String, email: String): User!
    deleteUser(id: ID!): Boolean!
  }

  type UsersResponse {
    data: [User!]!
    meta: PaginationMeta!
  }

  type PaginationMeta {
    page: Int!
    limit: Int!
    total: Int!
    totalPages: Int!
  }
`

// Resolvers (lógica)
const resolvers = {
  Query: {
    users: async (_: any, { page = 1, limit = 20, role }: any) => {
      const where = role ? { role } : {}

      const [data, total] = await Promise.all([
        prisma.user.findMany({
          where,
          skip: (page - 1) * limit,
          take: limit
        }),
        prisma.user.count({ where })
      ])

      return {
        data,
        meta: {
          page,
          limit,
          total,
          totalPages: Math.ceil(total / limit)
        }
      }
    },

    user: async (_: any, { id }: { id: string }) => {
      return prisma.user.findUnique({ where: { id } })
    },

    bookings: async (_: any, { userId, status }: any) => {
      return prisma.booking.findMany({
        where: {
          ...(userId && { userId }),
          ...(status && { status })
        }
      })
    }
  },

  Mutation: {
    createUser: async (_: any, { name, email, role }: any) => {
      return prisma.user.create({
        data: { name, email, role }
      })
    },

    updateUser: async (_: any, { id, ...data }: any) => {
      return prisma.user.update({
        where: { id },
        data
      })
    },

    deleteUser: async (_: any, { id }: { id: string }) => {
      await prisma.user.delete({ where: { id } })
      return true
    }
  },

  // Resolvers de campos (relações)
  User: {
    bookings: async (parent: any) => {
      return prisma.booking.findMany({
        where: { userId: parent.id }
      })
    },

    bookingsCount: async (parent: any) => {
      return prisma.booking.count({
        where: { userId: parent.id }
      })
    }
  },

  Booking: {
    user: async (parent: any) => {
      return prisma.user.findUnique({
        where: { id: parent.userId }
      })
    }
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers
})

const handler = startServerAndCreateNextHandler(server)

export { handler as GET, handler as POST }

Query no client:

// Cliente GraphQL
const QUERY = gql`
  query GetUsers($page: Int, $limit: Int) {
    users(page: $page, limit: $limit) {
      data {
        id
        name
        email
        bookingsCount  # ← Conta sem trazer todos os bookings
      }
      meta {
        total
        totalPages
      }
    }
  }
`

const { data } = await apolloClient.query({
  query: QUERY,
  variables: { page: 1, limit: 20 }
})

Vantagens GraphQL:

  • ✅ Sem over-fetching (client pede exato o que quer)
  • ✅ Sem under-fetching (1 request traz tudo)
  • ✅ Fortemente tipado (schema)
  • ✅ Introspection (auto-documentação)

Desvantagens GraphQL:

  • ❌ Complexidade inicial (setup, tooling)
  • ❌ Caching mais difícil (não usa HTTP cache)
  • ❌ N+1 queries (precisa DataLoader)
  • ❌ Over-engineering para MVPs simples

Webhooks (notificações assíncronas)

Webhook = API chama SEU servidor quando algo acontece.

Exemplo: Stripe webhook após pagamento.

// app/api/webhooks/stripe/route.ts
import { NextRequest } from 'next/server'
import Stripe from 'stripe'

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

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

  let event: Stripe.Event

  try {
    // Valida assinatura (segurança)
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return Response.json({ error: 'Invalid signature' }, { status: 400 })
  }

  // Processa evento
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent
      await handlePaymentSuccess(paymentIntent)
      break

    case 'payment_intent.payment_failed':
      const failedPayment = event.data.object as Stripe.PaymentIntent
      await handlePaymentFailure(failedPayment)
      break

    case 'customer.subscription.created':
      const subscription = event.data.object as Stripe.Subscription
      await handleSubscriptionCreated(subscription)
      break

    case 'customer.subscription.deleted':
      const deletedSub = event.data.object as Stripe.Subscription
      await handleSubscriptionCanceled(deletedSub)
      break

    default:
      console.log(`Unhandled event type: ${event.type}`)
  }

  // Sempre retorna 200 (ou Stripe reenvia)
  return Response.json({ received: true })
}

async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
  const bookingId = paymentIntent.metadata.bookingId

  await prisma.booking.update({
    where: { id: bookingId },
    data: { status: 'CONFIRMED', paidAt: new Date() }
  })

  // Envia confirmação por email
  await sendEmail({
    to: paymentIntent.metadata.customerEmail,
    subject: 'Pagamento confirmado!',
    template: 'booking-confirmed'
  })
}

Webhook próprio (notificar parceiros):

// lib/webhooks/send.ts
import crypto from 'crypto'

export async function sendWebhook(url: string, payload: any, secret: string) {
  const timestamp = Date.now()
  const signature = generateSignature(payload, timestamp, secret)

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Webhook-Signature': signature,
      'X-Webhook-Timestamp': timestamp.toString()
    },
    body: JSON.stringify(payload)
  })

  if (!response.ok) {
    throw new Error(`Webhook failed: ${response.status}`)
  }

  return response
}

function generateSignature(payload: any, timestamp: number, secret: string): string {
  const data = `${timestamp}.${JSON.stringify(payload)}`
  return crypto.createHmac('sha256', secret).update(data).digest('hex')
}

// Uso: notifica parceiro quando booking é confirmado
await sendWebhook(
  partner.webhookUrl,
  {
    event: 'booking.confirmed',
    data: { bookingId: '123', date: '2026-12-10' }
  },
  partner.webhookSecret
)

Retry lógico (se webhook falhar):

// lib/webhooks/retry.ts
export async function sendWebhookWithRetry(
  url: string,
  payload: any,
  secret: string,
  maxRetries = 3
) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await sendWebhook(url, payload, secret)
      console.log(`Webhook sent successfully (attempt ${attempt})`)
      return
    } catch (error) {
      console.error(`Webhook failed (attempt ${attempt}/${maxRetries})`, error)

      if (attempt === maxRetries) {
        // Após 3 tentativas, salva para reprocessamento manual
        await prisma.failedWebhook.create({
          data: {
            url,
            payload,
            error: error.message,
            attempts: maxRetries
          }
        })
        throw error
      }

      // Backoff exponencial (1s, 2s, 4s)
      await sleep(1000 * Math.pow(2, attempt - 1))
    }
  }
}

Rate limiting (proteção de API)

Sem rate limit = API pode ser abusada (DDoS, scraping).

Implementação (Redis + upstash):

npm install @upstash/ratelimit @upstash/redis
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()

// 10 requests por 10 segundos
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true
})

// Uso em API route
export async function GET(req: NextRequest) {
  const ip = req.ip ?? '127.0.0.1'

  const { success, limit, remaining, reset } = await ratelimit.limit(ip)

  if (!success) {
    return Response.json(
      {
        error: 'Rate limit exceeded',
        limit,
        remaining,
        reset: new Date(reset)
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString()
        }
      }
    )
  }

  // Continue processamento
  const data = await fetchData()
  return Response.json(data)
}

Rate limiting por tier (planos diferentes):

// lib/rate-limit-tier.ts
export function getRateLimitForPlan(plan: string) {
  const limits = {
    FREE: Ratelimit.slidingWindow(10, '10 s'),  // 10/10s
    PRO: Ratelimit.slidingWindow(100, '10 s'),  // 100/10s
    ENTERPRISE: Ratelimit.slidingWindow(1000, '10 s') // 1000/10s
  }

  return new Ratelimit({
    redis,
    limiter: limits[plan] || limits.FREE
  })
}

API versioning (retrocompatibilidade)

Opção 1: URL versioning

/api/v1/users
/api/v2/users
// app/api/v1/users/route.ts
export async function GET() {
  // Versão antiga (deprecated)
}

// app/api/v2/users/route.ts
export async function GET() {
  // Versão nova
}

Opção 2: Header versioning

export async function GET(req: NextRequest) {
  const version = req.headers.get('X-API-Version') || 'v1'

  if (version === 'v2') {
    return handleV2()
  } else {
    return handleV1()
  }
}

Deprecation warning:

export async function GET() {
  const data = await fetchUsers()

  return Response.json(data, {
    headers: {
      'X-API-Deprecated': 'true',
      'X-API-Sunset': '2027-01-01', // Data de desligamento
      'Link': '</api/v2/users>; rel="successor-version"'
    }
  })
}

Documentação automática

Swagger/OpenAPI (REST):

npm install swagger-ui-react swagger-jsdoc
// lib/swagger.ts
import swaggerJsdoc from 'swagger-jsdoc'

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'Conecta Prof API',
      version: '1.0.0',
      description: 'API para marketplace de professores'
    },
    servers: [
      { url: 'https://api.conectaprof.com' }
    ]
  },
  apis: ['./app/api/**/*.ts'] // Scan arquivos
}

export const swaggerSpec = swaggerJsdoc(options)
// app/api/users/route.ts
/**
 * @swagger
 * /api/users:
 *   get:
 *     summary: Lista usuários
 *     parameters:
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *         description: Número da página
 *     responses:
 *       200:
 *         description: Lista de usuários
 */
export async function GET(req: NextRequest) {
  // ...
}

GraphQL Playground (auto-documentado):

Acesse /api/graphql e GraphQL Playground mostra schema interativo.

Quando usar cada padrão

Use REST se:

  • API CRUD simples
  • Precisa caching HTTP
  • Integrações B2B (padrão da indústria)
  • MVP rápido

Use GraphQL se:

  • Client precisa flexibilidade (mobile, web, desktop)
  • Múltiplas relações (evitar N requests)
  • API pública com muitos consumers
  • Real-time (subscriptions)

Use Webhooks se:

  • Notificações assíncronas (pagamento aprovado, pedido enviado)
  • Integrações entre sistemas
  • Evitar polling (ineficiente)

Checklist de API production-ready

Segurança:

  • HTTPS obrigatório
  • Rate limiting implementado
  • Autenticação (JWT ou session)
  • Validação de input (Zod, Joi)
  • CORS configurado
  • Webhook signature validation

Performance:

  • Pagination em listas
  • Cache headers configurados
  • Database índices otimizados
  • N+1 queries resolvidos (DataLoader em GraphQL)

Developer Experience:

  • Documentação (Swagger ou GraphQL Playground)
  • Error messages claros
  • Exemplos de requests/responses
  • SDKs para linguagens principais (TypeScript, Python)

Monitoramento:

  • Logging de requests (método, path, status, latência)
  • Alertas de error rate
  • Dashboard de métricas (RPS, P95 latency)

Próximos passos

  1. Escolher padrão (REST para MVP)
  2. Setup validação (Zod)
  3. Implementar rate limiting
  4. Configurar CORS
  5. Documentar endpoints (Swagger)
  6. Adicionar testes (supertest)
  7. Monitorar performance (Datadog/Sentry)

Lembre-se: API é contrato. Mudanças quebram clientes. Versione adequadamente.

Pronto para sair do manual?

Agende o diagnóstico gratuito. Vamos mapear o gargalo, estimar o impacto e definir o primeiro resultado mensurável.

Você sai com clareza — não com um pitch de vendas.