Back to Blog
Engineering May 9, 2026 10 min read

Designing APIs That Return Predictable JSON

Why we spend as much time on response schemas as we do on model outputs—and how structured JSON saves developers hours of debugging.

When we started building RedAPI, we made a decision that seemed obvious in retrospect but wasn’t common in AI tooling at the time:

We would treat response schemas as a first-class concern, not an afterthought.

This post explains why that decision matters and how it affects our API design.

The Problem with AI Output

Traditional AI outputs are strings. Sometimes they’re formatted (markdown, JSON strings), but the API still returns text that you have to parse. This leads to:

  1. Parsing errors — The model didn’t output valid JSON
  2. Schema drift — Keys appear/disappear based on content
  3. Type instability — A field might be a string or an array depending on context
  4. Missing fields — Optional fields are sometimes omitted

Here’s an example of what happens when an AI API doesn’t enforce a schema:

// Request: "Summarize these reviews"
// Response 1 (happy path):
{
  "summary": "Customers like the product quality but complain about shipping",
  "sentiment": "mixed"
}

// Response 2 (when reviews are all negative):
{
  "verdict": "negative",
  "issues": ["slow shipping", "damaged product", "poor support"]
}

// Response 3 (when reviews are all positive):
{
  "summary": "Customers are very happy with the product"
}

Notice: different response shapes, different field names (summary vs verdict, sentiment vs the absence of it), inconsistent types.

This is a debugging nightmare.

Our Approach

Every RedAPI endpoint has a response schema that’s documented and enforced. The E-Commerce Review Summarizer always returns the same 12-field structure:

interface OutputSchema {
  overall_sentiment: 'very_positive' | 'positive' | 'mixed' | 'negative' | 'very_negative' | null;
  sentiment_percentage: { positive: number; neutral: number; negative: number };
  overall_rating_estimate: number | null;       // 1.0–5.0
  pros: string[];                                // Always an array
  cons: string[];                                // Always an array
  sentiment_by_aspect: {
    quality: number | null;                      // -1.0 to 1.0
    price: number | null;
    shipping: number | null;
    packaging: number | null;
    durability: number | null;
    customer_service: number | null;
    ease_of_use: number | null;
  };
  top_complaint: string | null;
  top_praise: string | null;
  trending_phrases: string[];
  competitor_mentions: string[];
  recommended_improvements: string[];
  executive_summary: string | null;
}

No matter what input you send, the output shape is consistent. You can rely on:

const response = await fetch('https://.../api/analyze', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', /* ... */ },
  body: JSON.stringify({ text: reviews.join('\n\n') })
});

const result = await response.json();
const { pros, cons, sentiment_by_aspect } = result.data;
// pros and cons are always arrays, never undefined
// sentiment_by_aspect always has all seven keys, each null or a number
TypeScript Tip

Each worker defines its output schema in src/types.ts. Copy the OutputSchema interface into your project to get full IDE support and compile-time type checking on every response field.

Why This Matters for Production Code

Consider error handling. With inconsistent schemas:

// What you write:
if (response.sentiment) {
  sendAlert(response.sentiment);
}

// What happens when sentiment is missing:
// No alert sent, silently fails

With consistent schemas:

// What you write:
if (result.data.sentiment_by_aspect.durability !== null
    && result.data.sentiment_by_aspect.durability < -0.5) {
  sendAlert(`Durability concern: ${result.data.sentiment_by_aspect.durability}`);
}

// What happens:
// Alert fires when the durability score drops below threshold.
// The field is always present — either a number or null — never missing.

The second version is predictable. You know what you’re getting. You can write reliable code.

Schema Design Principles

We apply three principles to every response schema:

1. Always Present Required Fields

If a field is always present in the response, it’s in the schema. No null-checks needed:

// Instead of: pros?: string[]
// We use: pros: string[] (always present, possibly empty)

The system prompt enforces this with an explicit rule: “You MUST populate every field in the output schema. Do NOT omit fields from the JSON object.” Empty arrays [] are acceptable when no items are found; null is reserved for fields where the information is genuinely not determinable from the input.

2. Typed Numeric Ranges

Sentiment-by-aspect scores are always -1.0 to 1.0, not “positive/negative/neutral”. Percentages are always 0–100, not “high/medium/low”. This lets you do math on the output:

const aspects = result.data.sentiment_by_aspect;
const negativeAspects = Object.entries(aspects)
  .filter(([, score]) => score !== null && score < -0.3)
  .map(([key]) => key);

console.log('Problem areas:', negativeAspects.join(', '));

3. Explicit Null vs Missing

We distinguish between “not applicable” (null) and “not present” (missing). If a field can be null, it’s explicitly null in the schema:

top_complaint: string | null  // Explicitly nullable, never omitted

This means downstream code never needs if ('top_complaint' in response) checks. The key always exists; you only check for null.

The Implementation

Achieving predictable JSON isn’t free. It requires defense in depth:

  1. System prompt enforcement — The model is instructed with explicit rules: populate every field, use null for unknowns, return [] for empty categories, never omit keys
  2. Markdown fence stripping — Route handlers strip ```json fences as a defense-in-depth measure before parsing
  3. JSON parse with error fallback — If JSON.parse throws, the handler returns a 500 with a debug_raw preview of the model’s output for support investigation

Here’s the actual pattern used in our route handlers:

// Step 1: Call Workers AI
const rawAIResponse = await callAI(userPrompt, SYSTEM_PROMPT, c.env);

// Step 2: Strip markdown fences (defense in depth)
const cleaned = rawAIResponse
  .replace(/^```json\s*/i, '')
  .replace(/^```\s*/i, '')
  .replace(/\s*```$/i, '')
  .trim();

// Step 3: Parse — if this fails, return 500 with debug info
let result: OutputSchema;
try {
  result = JSON.parse(cleaned) as OutputSchema;
} catch {
  return c.json({
    error: 'Internal Server Error',
    message: 'AI returned a response that could not be parsed as JSON. Please retry.',
    debug_raw: rawAIResponse.substring(0, 200),
  }, 500);
}

// Step 4: Return the structured response
return c.json({ success: true, cached: false, data: result });

There is no runtime normalization layer. The schema is enforced at the prompt level, validated at parse time, and the response envelope ({ success, cached, data }) is consistent across all workers.

The Developer Experience

The payoff is in how easy it is to use our APIs:

const result = await response.json();
const data = result.data;

console.log(`Sentiment: ${data.overall_sentiment}`);
console.log(`Rating estimate: ${data.overall_rating_estimate} / 5.0`);
console.log(`Top complaint: ${data.top_complaint}`);
console.log(`Competitors mentioned: ${data.competitor_mentions.join(', ') || 'None'}`);

// Works every time — pros and cons are always arrays, never undefined.
// Null fields are explicit: top_complaint is null when no complaints exist,
// not missing from the response.

That’s the experience we’re building toward. APIs that get out of your way and let you build.