Integrating Restaurant Health Scores into a Food Delivery Platform: A Complete Tutorial

Consumer trust is becoming the most important moat in food delivery. Price and speed were once the primary battlegrounds. Now that delivery times have converged across platforms and promotions are table stakes, the differentiators are increasingly about confidence - and nothing builds consumer confidence like knowing the restaurant you are ordering from passed its last health inspection with flying colors.

A 2024 survey by the Food Marketing Institute found that 71% of consumers said they would switch to a competing delivery platform if it showed government health inspection scores and their current platform did not. That is not a marginal preference - that is a feature gap large enough to move market share. Yet the engineering challenge of pulling, normalizing, and displaying inspection data from 3,000+ US jurisdictions has historically blocked most platforms from acting on this insight.

This tutorial walks through the complete food delivery platform restaurant health score integration: database schema, the nightly sync job, API response handling, front-end badge components, and geo-search filtering. For background on API integration fundamentals, that post covers the foundational concepts before you dive into this production-grade implementation.

Architecture Overview

Before writing a single line of code, it helps to understand where health score data fits inside a typical delivery platform data model. Most platforms already have a restaurants table with columns like id, name, address, latitude, longitude, cuisine_type, and operational flags. Health scores are a natural extension of that table - not a separate service, not a microservice with its own database - just additional columns that get refreshed on a schedule.

The sync architecture has three moving parts:

The geo search path is slightly different. When a user opens the map view and browses restaurants by location, you call GET /v1/restaurant/geo with the center coordinate and a radius. This endpoint returns scores for all matched restaurants in a single response, which you can join against your local restaurant records using the foodsafe_id field returned in the API payload.

Database Schema

Add the following columns to your existing restaurants table. If you are on Postgres (the most common backend for delivery platforms), the migration looks like this:

-- Migration: add health inspection columns to restaurants
ALTER TABLE restaurants
  ADD COLUMN health_score        SMALLINT,
  ADD COLUMN health_grade        CHAR(1),
  ADD COLUMN health_last_inspected DATE,
  ADD COLUMN health_violations_critical    SMALLINT DEFAULT 0,
  ADD COLUMN health_violations_noncritical SMALLINT DEFAULT 0,
  ADD COLUMN health_violations_corrected   SMALLINT DEFAULT 0,
  ADD COLUMN foodsafe_id         VARCHAR(64),
  ADD COLUMN health_score_cached_at TIMESTAMPTZ;

-- Index for grade-filtered queries (restaurant listing pages)
CREATE INDEX idx_restaurants_health_grade
  ON restaurants (health_grade)
  WHERE health_grade IS NOT NULL;

-- Index for score-range queries (find all A-grade places in a cuisine)
CREATE INDEX idx_restaurants_health_score
  ON restaurants (health_score DESC)
  WHERE health_score IS NOT NULL;

-- Index on foodsafe_id for fast upsert matching
CREATE UNIQUE INDEX idx_restaurants_foodsafe_id
  ON restaurants (foodsafe_id)
  WHERE foodsafe_id IS NOT NULL;

The health_score_cached_at timestamp is critical. It tells your sync job whether a record is stale and needs to be refreshed, without requiring a separate caching table. Any restaurant where health_score_cached_at < NOW() - INTERVAL '7 days' is a candidate for the next sync run.

The foodsafe_id column stores the stable identifier returned by the FoodSafe Score API. Once you have matched a restaurant by address and fuzzy name, you can use this ID for all future calls to GET /v1/restaurant/{id}/history, bypassing the lookup overhead entirely.

Schema Note

Store health_grade as a single CHAR(1) rather than deriving it from health_score at query time. Grade thresholds differ by jurisdiction in the underlying raw data - the API handles normalization, but storing the grade independently means your display logic stays simple and never has to re-derive it.

The Nightly Sync Job

The bulk endpoint is your workhorse. It accepts a ZIP code (or list of ZIP codes) and returns scored records for every restaurant the API knows about in that area. Here is a production-ready Node.js implementation:

// sync-health-scores.js
// Run nightly via cron: 0 3 * * * node sync-health-scores.js

const db = require('./db');
const axios = require('axios');

const FOODSAFE_API_KEY = process.env.FOODSAFE_API_KEY;
const FOODSAFE_BASE    = 'https://api.foodsafescoreapi.com/v1';
const STALE_DAYS       = 7;
const BATCH_SIZE       = 50; // max ZIP codes per bulk request

async function syncHealthScores() {
  // 1. Collect distinct ZIP codes for active restaurants with stale scores
  const { rows: zipRows } = await db.query(`
    SELECT DISTINCT zip_code
    FROM restaurants
    WHERE is_active = true
      AND (
        health_score_cached_at IS NULL
        OR health_score_cached_at < NOW() - INTERVAL '${STALE_DAYS} days'
      )
    ORDER BY zip_code
  `);

  const zips = zipRows.map(r => r.zip_code);
  console.log(`Syncing health scores for ${zips.length} ZIP codes`);

  // 2. Process in batches
  for (let i = 0; i < zips.length; i += BATCH_SIZE) {
    const batchZips = zips.slice(i, i + BATCH_SIZE);

    try {
      const response = await axios.post(
        `${FOODSAFE_BASE}/restaurant/bulk`,
        { zip_codes: batchZips },
        {
          headers: {
            'Authorization': `Bearer ${FOODSAFE_API_KEY}`,
            'Content-Type': 'application/json'
          },
          timeout: 30000
        }
      );

      if (response.status !== 200) {
        console.error(`Bulk API error: ${response.status}`, response.data);
        continue;
      }

      await upsertScores(response.data.restaurants);
    } catch (err) {
      console.error(`Batch ${i / BATCH_SIZE} failed:`, err.message);
      // Continue to next batch rather than aborting the entire run
    }

    // Brief pause between batches to stay within rate limits
    if (i + BATCH_SIZE < zips.length) {
      await new Promise(r => setTimeout(r, 300));
    }
  }

  console.log('Health score sync complete');
}

async function upsertScores(restaurants) {
  for (const r of restaurants) {
    // Fuzzy-match by foodsafe_id first, then fall back to address + name
    const match = await findLocalMatch(r);
    if (!match) continue;

    await db.query(`
      UPDATE restaurants SET
        health_score              = $1,
        health_grade              = $2,
        health_last_inspected     = $3,
        health_violations_critical    = $4,
        health_violations_noncritical = $5,
        health_violations_corrected   = $6,
        foodsafe_id               = $7,
        health_score_cached_at    = NOW()
      WHERE id = $8
    `, [
      r.score,
      r.grade,
      r.last_inspected,
      r.violations.critical,
      r.violations.non_critical,
      r.violations.corrected,
      r.id,
      match.id
    ]);
  }
}

async function findLocalMatch(apiRestaurant) {
  // Try exact foodsafe_id match first (fastest)
  const { rows: byId } = await db.query(
    'SELECT id FROM restaurants WHERE foodsafe_id = $1 LIMIT 1',
    [apiRestaurant.id]
  );
  if (byId.length) return byId[0];

  // Fall back to address + similarity match using pg_trgm
  const { rows: byAddress } = await db.query(`
    SELECT id, similarity(name, $1) AS sim
    FROM restaurants
    WHERE address_line1 = $2
      AND similarity(name, $1) > 0.4
    ORDER BY sim DESC
    LIMIT 1
  `, [apiRestaurant.name, apiRestaurant.address.street]);

  return byAddress.length ? byAddress[0] : null;
}

syncHealthScores().catch(console.error);

A few implementation details worth calling out. The fuzzy name match uses Postgres pg_trgm with a similarity threshold of 0.4. That is deliberately low because restaurant names often appear with slight variations across data sources - "McDonald's #4271" in your database versus "McDonalds" in the API response. A threshold below 0.3 produces too many false positives; above 0.55 you start missing valid matches for chains.

The continue on batch errors means a single bad ZIP code (one that returns a 400 because no restaurants are found) does not abort the entire nightly run. This matters at scale: if you have 800 ZIP codes to process and one of them hits a transient error, you want the other 799 to complete.

API Response Handling

A full response from POST /v1/restaurant/bulk looks like this:

{
  "restaurants": [
    {
      "id": "fsa_7x2k9m4p",
      "name": "The Golden Fork",
      "address": {
        "street": "1420 W Devon Ave",
        "city": "Chicago",
        "state": "IL",
        "zip": "60660"
      },
      "score": 91,
      "grade": "A",
      "last_inspected": "2026-02-14",
      "inspection_count": 12,
      "violations": {
        "critical": 0,
        "non_critical": 2,
        "corrected": 1
      },
      "jurisdiction": "Chicago Department of Public Health",
      "jurisdiction_score": 94,
      "normalized": true,
      "history_available": true
    },
    {
      "id": "fsa_3n8r1v6q",
      "name": "Sunrise Diner",
      "address": {
        "street": "842 N Clark St",
        "city": "Chicago",
        "state": "IL",
        "zip": "60610"
      },
      "score": null,
      "grade": null,
      "last_inspected": null,
      "inspection_count": 0,
      "violations": {
        "critical": 0,
        "non_critical": 0,
        "corrected": 0
      },
      "jurisdiction": "Chicago Department of Public Health",
      "normalized": false,
      "history_available": false
    }
  ],
  "total": 2,
  "zip_codes_processed": ["60660", "60610"]
}

The second record - Sunrise Diner - demonstrates the null score case. This happens with newly licensed restaurants that have not yet had their first inspection, restaurants that opened between inspection cycles, and occasionally with jurisdictions that have a lag in publishing their data publicly. Your code must handle score: null without crashing, and your UI must handle it without showing a misleading badge.

The caching strategy is straightforward: set a 7-day TTL on the health_score_cached_at column. The API refreshes its data weekly from source health departments. There is no value in fetching more frequently - you will get the same data and burn credits unnecessarily. The only exception is when a user explicitly requests a refresh on a restaurant's detail page, in which case you can call GET /v1/restaurant/{id} on-demand and update the cache immediately.

Frontend: The Score Badge Component

The badge is the most visible consumer-facing output of this entire integration. Keep it simple: the letter grade, color-coded, with a tooltip showing the numeric score and the date of last inspection. Here is the CSS and HTML for a grade badge that works in a standard restaurant card grid:

/* Grade badge styles */
.health-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  border-radius: 6px;
  font-size: 0.9rem;
  font-weight: 800;
  letter-spacing: -0.02em;
  position: relative;
  cursor: default;
}

.health-badge[data-grade="A"] { background: #16a34a; color: #fff; }
.health-badge[data-grade="B"] { background: #65a30d; color: #fff; }
.health-badge[data-grade="C"] { background: #d97706; color: #fff; }
.health-badge[data-grade="F"] { background: #dc2626; color: #fff; }
.health-badge[data-grade="?"] { background: #475569; color: #fff; }

/* Tooltip */
.health-badge::after {
  content: attr(data-tooltip);
  position: fixed; /* fixed avoids overflow:auto clipping */
  transform: translateX(-50%);
  background: #1e293b;
  color: #f1f5f9;
  font-size: 0.7rem;
  font-weight: 400;
  white-space: nowrap;
  padding: 0.4rem 0.75rem;
  border-radius: 6px;
  border: 1px solid #334155;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s;
  z-index: 9999;
}

.health-badge:hover::after {
  opacity: 1;
}
// React component (or vanilla JS equivalent)
function HealthBadge({ score, grade, lastInspected }) {
  if (!grade) {
    return (
      <span
        className="health-badge"
        data-grade="?"
        data-tooltip="Inspection data not yet available"
      >
        ?
      </span>
    );
  }

  const formattedDate = lastInspected
    ? new Date(lastInspected).toLocaleDateString('en-US', {
        month: 'short', day: 'numeric', year: 'numeric'
      })
    : 'Unknown';

  return (
    <span
      className="health-badge"
      data-grade={grade}
      data-tooltip={`Score: ${score}/100 - Inspected ${formattedDate}`}
    >
      {grade}
    </span>
  );
}

Place the badge in the top-right corner of the restaurant card image, or immediately after the restaurant name in a list view. Avoid placing it below the fold of the card - users will not see it. The badge should be present on every restaurant in every view, including search results, cuisine category pages, and favorites lists. Consistency is what builds the trust signal.

For more on best practices for displaying health scores to consumers, including messaging guidance and accessibility considerations, that post covers the UX side in depth.

The Restaurant Detail Page

The badge on the listing page drives curiosity. The detail page is where you convert that curiosity into trust. A full score breakdown section on the restaurant detail page should include:

For the trend chart, call GET /v1/restaurant/{id}/history. This endpoint returns the last N inspections for a given restaurant, ordered by date descending. Cache this response alongside the base score - it changes on the same weekly cadence.

// Fetch inspection history for detail page
async function getInspectionHistory(foodsafeId) {
  const cached = await redis.get(`history:${foodsafeId}`);
  if (cached) return JSON.parse(cached);

  const res = await fetch(
    `https://api.foodsafescoreapi.com/v1/restaurant/${foodsafeId}/history?limit=5`,
    { headers: { 'Authorization': `Bearer ${process.env.FOODSAFE_API_KEY}` } }
  );

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.message || `History fetch failed (${res.status})`);
  }

  const data = await res.json();

  // Cache for 7 days
  await redis.setex(`history:${foodsafeId}`, 604800, JSON.stringify(data));
  return data;
}

Geo Search Integration

The map view is where health scores add the most visceral value. When a user browses restaurants on a map, showing a colored grade indicator on each pin transforms the map from a location tool into a trust layer. Use GET /v1/restaurant/geo to retrieve scores for all restaurants within the map's current viewport:

GET /v1/restaurant/geo?lat=41.8827&lng=-87.6233&radius=2.5&unit=miles

// Response includes:
{
  "restaurants": [
    { "id": "fsa_7x2k9m4p", "score": 91, "grade": "A", "lat": 41.8891, "lng": -87.6219, ... },
    { "id": "fsa_9p3w5n2r", "score": 63, "grade": "C", "lat": 41.8802, "lng": -87.6301, ... }
  ],
  "total": 48,
  "radius_miles": 2.5
}

Join the geo response against your local restaurant records using foodsafe_id. Any local restaurant without a foodsafe_id yet can be matched by coordinates (your latitude/longitude plus a small tolerance). Update the foodsafe_id column when you find a match - this progressively enriches your data without requiring a full resync.

For the map filter panel, add a "Health Grade" multi-select: A, B, C, F, and "Not Yet Rated". This filter operates purely on your local database - no additional API calls needed, because the scores are already stored locally. The filter query is a simple WHERE health_grade = ANY($1) on the restaurants table.

Consumer Messaging

The disclosure copy around health scores requires care. Government inspection data is authoritative but can feel opaque to consumers who have never thought about what a "critical violation" actually means. These copy patterns have tested well:

Legal Note

Add a brief attribution line near the score on the detail page noting that inspection data is sourced from public government health department records. This protects you from liability if a score is outdated between inspection cycles. The FoodSafe Score API terms of service include standard data attribution language you can use verbatim.

Putting It All Together

The full integration path is: run the schema migration, deploy the nightly sync job, update your restaurant card component to render the HealthBadge, add the score breakdown section to the detail page template, and wire up the geo search filter. A mid-sized platform with 10,000 active restaurants across 400 ZIP codes will complete a full sync in under 90 seconds using the bulk endpoint - well within a nightly maintenance window.

The ongoing API cost at the $0.25/lookup rate only applies to single lookups. The bulk endpoint and geo endpoint are covered under the monthly plan pricing, making the economics favorable at scale. A platform with 10,000 restaurants running a weekly sync at the $99/month tier pays less than a dollar per restaurant per month for continuously fresh safety data.

Consumer trust compounds over time. Users who see consistent grade A restaurants in their order history start filtering by grade before they consciously think about it. That behavioral pattern - where health-conscious users cluster on your platform because you show the data they care about - is a sustainable acquisition and retention advantage that competitors without this integration cannot easily replicate.

Ready to Add Health Scores to Your Platform?

Get early access to the FoodSafe Score API and start building consumer trust with government-backed inspection data.

Join the Waitlist