---
title: "Data Validation"
description: "Ring Platform input validation architecture — Zod schemas, route-boundary guards, webhook HMAC verification, and business data safety for founders and developers"
locale: "en"
---
# Data Validation

> **Info**
> Use **Founder** / **Developer** tabs in the docs sidebar. Founders: learn how validation protects your business data. Developers: get the full Zod pattern reference with real codebase examples.

Every API call to your Ring clone is a contract: the client promises well-formed data, and the server promises to process it safely. **Data validation** is how Ring Platform enforces that contract — rejecting malformed input before it can corrupt records, create phantom orders, or bypass payment verification.

## Validation layers at a glance

| Layer | What it catches | Who owns it |
|-------|----------------|-------------|
| **Route boundary** | Missing fields, wrong types, malformed JSON | Zod schemas in `/app/api/**` route handlers |
| **Service boundary** | Business rule violations (wrong role, missing ownership) | Domain services in `features/*/services/` |
| **Database boundary** | Constraint violations (NOT NULL, UNIQUE, CHECK) | PostgreSQL `data/schema.sql` constraints |

Each layer adds a narrow guarantee. No single layer replaces the others.

### For founders

## Why validation protects your business

### The cost of bad data

Without validation, a malformed API request can:

- **Create ghost records** — an opportunity with no title appears in search results
- **Corrupt payment records** — a webhook with a spoofed signature triggers a refund
- **Break user experience** — invalid preferences crash the settings page
- **Leak confidential data** — a missing visibility check exposes deal-room listings

Ring Platform's three-layer validation catches these before they reach your database.

### What this means for your clone

When you **ringize** a deployment, you inherit validation that:

- **Rejects incomplete entity profiles** — name, type, location, and visibility are required before a record is created
- **Verifies payment webhooks** — WayForPay HMAC signatures are checked before any order status changes
- **Guards confidential tiers** — role validation happens before confidential entity/opportunity data is returned
- **Prevents preference injection** — user settings are validated against a known schema, so unknown fields can't sneak in

### Business scenarios

  
- **[Payment webhook spoofing](/docs/architecture/payment-conductor.md)** — An attacker sends a fake "payment completed" webhook. Ring validates the HMAC signature first — if it doesn't match, the webhook is rejected before any order status changes.

  
- **[Opportunity creation](/docs/features/opportunities.md)** — A client submits an opportunity without a title or category. Zod rejects it at the route boundary with a clear error message — no ghost record is created.

  
- **[User preferences](/docs/features/authentication.md)** — A malicious client tries to inject unknown fields into their preferences. The Zod schema strips unknown keys — only locale, currency, and theme are accepted.

  
- **[Confidential deal room](/docs/features/security.md)** — A subscriber tries to access confidential opportunities. The route gate checks role before the query runs — the database never sees the unauthorized request.

  Validation is not just a developer concern — it directly protects your revenue, your users' trust, and your compliance posture. Every Ring clone ships with these guards enabled by default.

### For developers

## Zod schema architecture

Ring Platform uses [Zod](https://zod.dev) as the single validation library across all API routes, Server Actions, and webhook handlers. Schemas serve as both runtime validators and TypeScript type generators via `z.infer<>`.

### Schema locations (SSOT)

| Location | Purpose | Examples |
|----------|---------|---------|
| `lib/zod/` | Shared cross-feature schemas | `credit-schemas.ts`, `store-product.ts`, `desk-schemas.ts`, `airdrop-schemas.ts` |
| `features/*/types/` | Feature-specific schemas | `features/opportunities/types/`, `features/news/types/` |
| `app/api/**/route.ts` | Route-local schemas | Inline schemas for MCP and webhook routes |

### Canonical route-boundary pattern

Every API route follows this pattern — parse, validate, then use typed data:

{`import { z } from 'zod'

const createEntitySchema = z.object({
  name: z.string().min(1, 'name is required'),
  shortDescription: z.string().min(1, 'shortDescription is required'),
  type: z.string().min(1, 'entity type is required'),
  location: z.string().min(1, 'location is required'),
  visibility: z.enum(['public', 'subscriber', 'member', 'confidential']),
  isConfidential: z.boolean(),
}).passthrough()

export async function POST(request: NextRequest) {
  const raw = await request.json()
  const parsed = createEntitySchema.safeParse(raw)

  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0]?.message ?? 'Invalid request body' },
      { status: 400 },
    )
  }

  // parsed.data is now fully typed — no 'as any' needed
  const entity = await createEntity(parsed.data)
  return NextResponse.json(entity, { status: 201 })
}`}

  Never use `body as any` to bypass TypeScript when calling service functions. If the service expects a specific type, create a Zod schema that validates the shape, then cast the validated output: `parsed.data as Parameters[0]`. The Zod validation ensures the cast is safe.

### z.preprocess for webhook normalization

Payment webhooks (WayForPay, Stripe) arrive in various formats. `z.preprocess` normalizes the raw body before validation — but for HMAC-verified webhooks, **never transform values** (HMAC uses `Object.values()` on the raw payload):

{`function normalizeWayforPayBody(raw: unknown): unknown {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
    throw new Error('WayForPay webhook payload must be a JSON object')
  }
  return raw  // NO value transformation — HMAC integrity depends on raw values
}

const wayforpayWebhookSchema = z.preprocess(
  normalizeWayforPayBody,
  z.object({
    orderReference: z.string().min(1),
    merchantSignature: z.string().min(1),
    merchantAccount: z.string().optional(),
    transactionStatus: z.string().optional(),
    amount: z.union([z.number(), z.string()]).optional(),
  }).passthrough()
)`}

For webhooks with multiple wire formats (like Web Vitals metrics), `z.preprocess` can normalize into a canonical shape:

{`function normalizeWebVitalsInput(raw: unknown): unknown {
  if (!raw || typeof raw !== 'object') return raw
  const body = raw as Record<string, unknown>

  // Single-metric wrapper
  if (body.type === 'single-metric' && body.metric) {
    return { sessionId: String(body.metric.sessionId ?? ''), metrics: [body.metric] }
  }
  // Batch-report wrapper
  if (body.type === 'batch-report' && body.report) {
    return { sessionId: String(body.report.sessionId ?? ''), metrics: body.report.metrics }
  }
  // Raw metric (no wrapper)
  if (typeof body.name === 'string' && typeof body.value === 'number') {
    return { sessionId: String(body.sessionId ?? ''), metrics: [body] }
  }
  return body  // pass-through, schema catches mismatches
}

const schema = z.preprocess(normalizeWebVitalsInput, webVitalsPayloadSchema)`}

### Conditional validation with superRefine

When validation rules depend on field values (e.g., metadata requirements vary by conversation type), use `superRefine`:

{`const createConversationSchema = z.object({
  type: z.enum(['direct', 'entity', 'opportunity', 'product', 'group']),
  participantIds: z.array(z.string()).min(1),
  metadata: z.object({
    entityId: z.string().optional(),
    opportunityId: z.string().optional(),
    productId: z.string().optional(),
    /** Optional subtype (e.g. generative_gallery); open string today — see /docs/features/messaging */
    kind: z.string().optional(),
    hiddenFromInbox: z.boolean().optional(),
  }).optional(),
}).passthrough().superRefine((data, ctx) => {
  if (data.type !== 'direct' && !data.metadata) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: \`metadata is required for \${data.type} conversations\`,
      path: ['metadata'],
    })
  }
  if (data.type === 'entity' && !data.metadata?.entityId) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'entityId is required for entity conversations',
      path: ['metadata', 'entityId'],
    })
  }
})`}

### Enum extraction from TypeScript enums

For large TypeScript enums (like `NotificationType` with 27 values), extract values at runtime for Zod:

{`import { NotificationType, NotificationPriority } from '@/features/notifications/types'

const typeValues = Object.values(NotificationType) as [string, ...string[]]
const priorityValues = Object.values(NotificationPriority) as [string, ...string[]]

const createNotificationSchema = z.object({
  title: z.string().min(1),
  body: z.string().min(1),
  type: z.enum(typeValues),
  priority: z.enum(priorityValues).default(NotificationPriority.NORMAL),
})`}

### after() for non-blocking analytics writes

Analytics routes use Next.js 16 `after()` to respond immediately while DB writes happen in the background. This eliminates ~50ms of blocking latency per analytics call:

{`import { after } from 'next/server'

export async function POST(request: NextRequest) {
  const parsed = analyticsSchema.safeParse(await request.json())
  if (!parsed.success) return errorResponse(parsed.error)

  const storageDisabled = isAnalyticsStorageDisabled()

  if (!storageDisabled) {
    after(async () => {
      const session = await auth().catch(() => null)
      await insertAnalyticsBatch(parsed.data, session?.user?.id)
        .catch(err => console.error('background write failed:', err))
    })
  }

  return NextResponse.json({ success: true, storageSkipped: storageDisabled })
}`}

### HMAC + Zod for payment webhooks

Payment webhook processing combines Zod shape validation with HMAC signature verification:

```mermaid
flowchart LR
  A[Webhook POST] --> B[Zod: validate shape]
  B -->|invalid| C[400 Bad Request]
  B -->|valid| D[HMAC: verify signature]
  D -->|mismatch| E[401 Unauthorized]
  D -->|match| F[Dispatch to handler]
  F --> G[Idempotent order update]
```

### Parse and validate shape

Zod ensures the payload has required fields (`orderReference`, `merchantSignature`, `amount`, etc.) before any processing.

### Verify HMAC signature

Recompute the HMAC-MD5 from payload values using the merchant secret key. Compare with `merchantSignature`. Reject on mismatch.

### Dispatch by order reference

Parse the `orderReference` prefix to determine the order type (store, membership, news promotion) and route to the correct handler.

### Idempotent update

Handlers check existing order status before updating — duplicate webhooks are safely ignored.

### MCP route validation

All `/api/mcp/v1/*` routes use Zod schemas with `.passthrough()` for forward compatibility. This allows MCP clients to send additional fields without breaking, while still validating required fields:

{`// Every MCP route follows this pattern:
const schema = z.object({
  // Required fields with clear error messages
  title: z.string().min(1, 'title is required'),
  // Optional fields with type constraints
  status: z.enum(['draft', 'active', 'archived']).optional(),
}).passthrough()  // Allow unknown fields for forward compatibility

export const POST = withMcpGuard(async (request) => {
  const parsed = schema.safeParse(await readJsonBody(request))
  if (!parsed.success) {
    return mcpError(parsed.error.issues[0]?.message ?? 'Invalid body', 400)
  }
  return mcpOk(await service.create(parsed.data), 201)
})`}

### Anti-patterns eliminated

| Anti-pattern | Why it's dangerous | Replacement |
|-------------|-------------------|-------------|
| `body as any` | Bypasses all type checking | Zod `safeParse` + typed output |
| Manual `if` chains | Easy to miss edge cases | Single `schema.safeParse()` call |
| Mutating parsed body | Side effects break predictability | Spread into new object |
| No route validation | Service receives garbage | Zod at route boundary |
| `as Record<string, unknown>` on DB reads | Hides type mismatches | `schema.safeParse(dbResult.data)` |

## Related documentation

  
- **[Security Model](/docs/architecture/security.md)** — RBAC, confidential tiers, API hardening checklist, and layout-level auth gates.

  
- **[Data Model](/docs/architecture/data-model.md)** — JSONB document contract, schema.sql SSOT, and DatabaseService patterns.

  
- **[PaymentConductor](/docs/architecture/payment-conductor.md)** — HMAC webhook verification, idempotent order references, and settlement flows.

  
- **[Best Practices](/docs/development/best-practices.md)** — Server Action conventions, database access patterns, and error handling.

  
- **[Authentication](/docs/architecture/authentication.md)** — Auth.js v5 sessions, role enum SSOT, and multi-provider setup.
