# How to Build a Shipping Integration with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a multi-carrier shipping integration in Lovable that compares rates from UPS, FedEx, and USPS via a Supabase Edge Function proxy, stores shipments and tracking data, processes carrier webhook updates, and saves label PDFs to Supabase Storage — giving you a complete shipping ops dashboard without exposing carrier API keys to the browser.

## Before you start

- Lovable Pro account for multiple Edge Functions
- EasyPost account with production or test API key (easypost.com — free account available)
- EasyPost API key saved to Cloud tab → Secrets as EASYPOST_API_KEY
- Supabase service role key saved as SUPABASE_SERVICE_ROLE_KEY
- Supabase Storage bucket for labels (created in step 1)
- Basic understanding of shipping concepts: origin/destination, parcel dimensions, tracking numbers

## Step-by-step guide

### 1. Set up the shipping database schema and Storage bucket

Prompt Lovable to create all the tables, the labels Storage bucket, and the database indexes needed for the shipping integration. Addresses are stored as JSONB to handle the variety of carrier address formats.

```
Create a shipping integration schema in Supabase:

1. Create a private Storage bucket named 'shipping-labels' with public: false, file size limit 5MB, allowed MIME types: application/pdf

2. Database tables:
   - addresses: id, user_id, label (text, e.g. 'Warehouse A'), name, company, street1, street2, city, state, zip, country (default 'US'), phone, is_verified (bool default false), created_at
   - shipments: id, user_id, origin_address_id, destination_address_id, carrier (text), service (text), tracking_number (text, unique), status (pre_transit|in_transit|out_for_delivery|delivered|failure|cancelled), label_url (text, storage path), rate_amount (numeric), currency (text default 'USD'), easypost_shipment_id (text), easypost_tracker_id (text), estimated_delivery_date (date), created_at, updated_at
   - tracking_events: id, shipment_id, status (text), description (text), location (text), carrier_timestamp (timestamptz), received_at
   - rates_cache: id, cache_key (text, unique), rates_json (jsonb), origin_zip, destination_zip, weight_oz, cached_at, expires_at
   - parcels: id, user_id, label (text), length_in (numeric), width_in (numeric), height_in (numeric), weight_oz (numeric), is_default (bool default false)

3. RLS:
   - addresses: users own their rows
   - shipments: users own their rows
   - tracking_events: users can SELECT where shipment_id is in their shipments. Service role for INSERT.
   - rates_cache: service role only
   - parcels: users own their rows

4. Storage RLS for shipping-labels:
   - SELECT: auth.uid()::text = (storage.foldername(name))[1]
   - INSERT: auth.uid()::text = (storage.foldername(name))[1]

Add indexes:
- CREATE INDEX idx_shipments_tracking ON shipments(tracking_number);
- CREATE INDEX idx_shipments_status ON shipments(user_id, status, created_at DESC);
- CREATE INDEX idx_rates_cache_key ON rates_cache(cache_key, expires_at);
```

> Pro tip: Store the EasyPost shipment ID (easypost_shipment_id) on every shipment row. If a webhook arrives for a tracking number you cannot find, you can look up the EasyPost shipment directly by its ID as a fallback.

**Expected result:** All tables are created with RLS policies and indexes. The shipping-labels Storage bucket is created as private. TypeScript types are generated.

### 2. Build the multi-carrier rate comparison Edge Function

Create the Edge Function that calls EasyPost's Shipment API to get rates from multiple carriers, caches the result, and returns normalized rate options to the frontend.

```
// supabase/functions/get-shipping-rates/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const { from_address, to_address, parcel } = await req.json()

  // Generate cache key from normalized inputs
  const cacheKey = btoa(JSON.stringify([
    from_address.zip, to_address.zip,
    parcel.weight, parcel.length, parcel.width, parcel.height
  ]))

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  // Check cache
  const { data: cached } = await supabase
    .from('rates_cache')
    .select('rates_json')
    .eq('cache_key', cacheKey)
    .gt('expires_at', new Date().toISOString())
    .single()

  if (cached) {
    return new Response(JSON.stringify({ rates: cached.rates_json, cached: true }), { headers: corsHeaders })
  }

  // Call EasyPost API
  const easypostRes = await fetch('https://api.easypost.com/v2/shipments', {
    method: 'POST',
    headers: {
      Authorization: `Basic ${btoa(Deno.env.get('EASYPOST_API_KEY') + ':')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      shipment: {
        to_address,
        from_address,
        parcel: {
          weight: parcel.weight,
          length: parcel.length,
          width: parcel.width,
          height: parcel.height,
        },
      },
    }),
  })

  const shipmentData = await easypostRes.json()

  const rates = (shipmentData.rates ?? []).map((r: any) => ({
    id: r.id,
    carrier: r.carrier,
    service: r.service,
    rate: parseFloat(r.rate),
    currency: r.currency,
    delivery_days: r.delivery_days,
    delivery_date: r.delivery_date,
    easypost_shipment_id: shipmentData.id,
  }))

  // Cache for 30 minutes
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString()
  await supabase.from('rates_cache').upsert({
    cache_key: cacheKey,
    rates_json: rates,
    origin_zip: from_address.zip,
    destination_zip: to_address.zip,
    weight_oz: parcel.weight,
    cached_at: new Date().toISOString(),
    expires_at: expiresAt,
  }, { onConflict: 'cache_key' })

  return new Response(JSON.stringify({ rates, cached: false }), { headers: corsHeaders })
})
```

> Pro tip: Sort the returned rates array by rate ascending before sending to the frontend so the cheapest option is always first: rates.sort((a, b) => a.rate - b.rate). Show the cheapest and fastest options with distinct badges in the UI.

**Expected result:** The Edge Function returns a sorted list of carrier rates for a given origin/destination/parcel combination. Second call with the same parameters returns cached results (cached: true) in under 50ms.

### 3. Build the label purchase Edge Function

Create the Edge Function that buys a shipping label via EasyPost, stores the PDF in Supabase Storage, and creates the shipment database row. This is called when the user selects a rate and confirms the purchase.

```
Create a Supabase Edge Function at supabase/functions/buy-label/index.ts.

Accept POST body: { rate_id: string, easypost_shipment_id: string, origin_address_id: string, destination_address_id: string }

Logic:
1. Authenticate the user via the Authorization header (user-scoped Supabase client)
2. Call EasyPost buy endpoint: POST https://api.easypost.com/v2/shipments/{easypost_shipment_id}/buy with body { rate: { id: rate_id } }
3. From the response, extract: tracking_code, selected_rate (carrier, service, rate, currency), postage_label.label_url (PDF URL), tracker.id
4. Fetch the PDF from label_url: const pdfRes = await fetch(label_url); const pdfBuffer = await pdfRes.arrayBuffer()
5. Store the PDF in Supabase Storage at path: {user_id}/{tracking_code}.pdf using service role client
6. Create a shipments row with: user_id, origin_address_id, destination_address_id, carrier, service, tracking_number, status='pre_transit', label_url (storage path), rate_amount, easypost_shipment_id, easypost_tracker_id
7. Return { shipment_id, tracking_number, label_storage_path, carrier, rate_amount }

Handle errors: if EasyPost returns an error (e.g. rate expired), return 422 with the EasyPost error message so the frontend can prompt the user to refresh rates.
```

**Expected result:** Calling the Edge Function with a rate ID purchases the label via EasyPost. The PDF is stored in Supabase Storage. A shipment row appears in the database with tracking number and pre_transit status.

### 4. Build the tracking webhook receiver

Create a public Edge Function that EasyPost calls with tracking events. It verifies the webhook signature, parses the event, updates the shipment status, and inserts a tracking_events row. Register this function's URL in the EasyPost dashboard as a webhook endpoint.

```
// supabase/functions/tracking-webhook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const STATUS_MAP: Record<string, string> = {
  pre_transit: 'pre_transit',
  in_transit: 'in_transit',
  out_for_delivery: 'out_for_delivery',
  delivered: 'delivered',
  failure: 'failure',
  return_to_sender: 'cancelled',
  cancelled: 'cancelled',
  error: 'failure',
  unknown: 'in_transit',
}

serve(async (req: Request) => {
  if (req.method !== 'POST') {
    return new Response('Method not allowed', { status: 405 })
  }

  const body = await req.text()
  let event: any
  try {
    event = JSON.parse(body)
  } catch {
    return new Response('Invalid JSON', { status: 400 })
  }

  // Only process tracker.updated events
  if (event.description !== 'tracker.updated') {
    return new Response('OK', { status: 200 })
  }

  const tracker = event.result
  const trackingNumber = tracker?.tracking_code
  const easypostStatus = tracker?.status

  if (!trackingNumber) return new Response('OK', { status: 200 })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: shipment } = await supabase
    .from('shipments')
    .select('id')
    .eq('tracking_number', trackingNumber)
    .single()

  if (!shipment) return new Response('OK', { status: 200 })

  const normalizedStatus = STATUS_MAP[easypostStatus] ?? 'in_transit'

  // Update shipment status
  await supabase
    .from('shipments')
    .update({ status: normalizedStatus, updated_at: new Date().toISOString() })
    .eq('id', shipment.id)

  // Insert tracking events
  const events = (tracker.tracking_details ?? []).slice(-5)
  if (events.length > 0) {
    await supabase.from('tracking_events').insert(
      events.map((e: any) => ({
        shipment_id: shipment.id,
        status: e.status,
        description: e.message,
        location: [e.tracking_location?.city, e.tracking_location?.state].filter(Boolean).join(', '),
        carrier_timestamp: e.datetime,
        received_at: new Date().toISOString(),
      }))
    )
  }

  return new Response('OK', { status: 200 })
})
```

**Expected result:** After registering the Edge Function URL in EasyPost, tracking updates arrive and automatically update shipment status and add tracking_events rows. The shipment dashboard reflects live carrier updates.

### 5. Build the shipping dashboard and rate comparison UI

Create the main shipping dashboard with a DataTable of all shipments, a new shipment flow with rate comparison cards, and a tracking timeline Sheet.

```
Build the shipping dashboard at src/pages/ShippingDashboard.tsx and a rate comparison flow.

1. Dashboard page:
- Stats row: total shipments today, in transit count, delivered today count, and average shipping cost
- Shipments DataTable: columns = origin (from address name), destination (to address name + city), carrier+service Badge, tracking number (monospace, copyable), status Badge (color-coded), cost, created date, Actions
- Status Badge colors: pre_transit=gray, in_transit=blue, out_for_delivery=yellow, delivered=green, failure=red, cancelled=muted
- Actions DropdownMenu: View Tracking (opens Sheet), Download Label (calls Supabase storage.createSignedUrl for 5 min and triggers download)
- Filter row: status Select filter, carrier Select filter, date range Popover

2. New Shipment flow (multi-step Dialog or separate page):
- Step 1: address form (from/to). Show saved addresses as Select options. Allow new address entry.
- Step 2: parcel dimensions form (length, width, height in inches, weight in oz)
- Step 3: rate comparison - call get-shipping-rates Edge Function, show results as Cards sorted by price. Each card shows carrier logo text, service name, price, delivery days. 'Select' Button highlights the chosen rate.
- Step 4: confirm and buy - show summary, 'Buy Label' Button calls buy-label Edge Function. Show loading state.

3. Tracking Sheet (ShipmentTracking.tsx):
- Shows shipment header: tracking number, carrier, destination, current status Badge
- Vertical timeline of tracking_events ordered by carrier_timestamp DESC
- Each event: colored dot (green for delivered, yellow for in transit, red for failure), status label, description, location, timestamp
- 'Refresh' Button that re-fetches tracking_events
```

**Expected result:** The dashboard shows all shipments with live status badges. The new shipment flow guides users through address, parcel, rate comparison, and label purchase. The tracking Sheet shows the event timeline.

## Complete code example

File: `supabase/functions/tracking-webhook/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const STATUS_MAP: Record<string, string> = {
  pre_transit: 'pre_transit',
  in_transit: 'in_transit',
  out_for_delivery: 'out_for_delivery',
  delivered: 'delivered',
  failure: 'failure',
  return_to_sender: 'cancelled',
  cancelled: 'cancelled',
  error: 'failure',
  unknown: 'in_transit',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok')
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 })

  let event: any
  try {
    event = await req.json()
  } catch {
    return new Response('Invalid JSON', { status: 400 })
  }

  if (!['tracker.created', 'tracker.updated'].includes(event.description)) {
    return new Response('OK', { status: 200 })
  }

  const tracker = event.result
  const trackingNumber = tracker?.tracking_code
  if (!trackingNumber) return new Response('OK', { status: 200 })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: shipment } = await supabase
    .from('shipments')
    .select('id, status')
    .eq('tracking_number', trackingNumber)
    .single()

  if (!shipment) return new Response('OK', { status: 200 })

  const newStatus = STATUS_MAP[tracker.status] ?? 'in_transit'

  if (shipment.status !== newStatus) {
    await supabase
      .from('shipments')
      .update({
        status: newStatus,
        updated_at: new Date().toISOString(),
        ...(newStatus === 'delivered' ? { actual_delivery_date: new Date().toISOString() } : {}),
      })
      .eq('id', shipment.id)
  }

  const latestEvents = (tracker.tracking_details ?? []).slice(0, 10)
  if (latestEvents.length > 0) {
    // Use upsert based on carrier_timestamp to avoid duplicates
    await supabase.from('tracking_events').upsert(
      latestEvents.map((e: any) => ({
        shipment_id: shipment.id,
        status: e.status ?? newStatus,
        description: e.message ?? '',
        location: [
          e.tracking_location?.city,
          e.tracking_location?.state,
          e.tracking_location?.country,
        ].filter(Boolean).join(', '),
        carrier_timestamp: e.datetime ?? new Date().toISOString(),
        received_at: new Date().toISOString(),
      })),
      { onConflict: 'shipment_id,carrier_timestamp', ignoreDuplicates: true }
    )
  }

  return new Response('OK', { status: 200 })
})
```

## Common mistakes

- **Calling carrier APIs directly from the Lovable frontend** — UPS, FedEx, and USPS API keys in the browser network tab are immediately exposed. Any attacker can use your carrier account to purchase labels at your expense. Fix: All carrier API calls must go through Supabase Edge Functions. Store all carrier API keys in Cloud tab → Secrets without VITE_ prefix. The frontend only calls your own Edge Functions, never carrier APIs directly.
- **Not caching rate responses** — Carrier rate APIs are slow (200–800ms per carrier) and charge per request on some plans. A user clicking back and forth between the rate selection step triggers multiple identical API calls. Fix: Cache rate responses in the rates_cache table keyed by a hash of origin zip, destination zip, and parcel dimensions. Set a 30-minute TTL. The second rate request with the same parameters returns instantly from the cache.
- **Storing tracking webhook payloads in full without filtering** — EasyPost webhook payloads can be very large (10KB+) with many nested tracking events. Storing each full payload wastes database storage and makes queries slow. Fix: Extract only the fields you need from the webhook payload: tracking_code, status, and the last 10 tracking_details events. Normalize them into structured tracking_events rows. Discard the rest of the payload after processing.
- **Not handling EasyPost rate expiry when buying labels** — EasyPost rates expire after approximately 15–30 minutes. If the user takes too long on the confirmation step, the rate ID is no longer valid and the buy call returns an error. Fix: Add a timestamp to each rate response. If more than 20 minutes have passed since the rates were fetched, show a 'Rates may have expired. Refresh rates?' prompt before allowing the buy step to proceed. Handle the EasyPost rate_expired error code in the buy-label Edge Function and return a 422 that prompts the frontend to re-fetch.

## Best practices

- Use EasyPost test mode API keys during development. EasyPost test mode simulates carrier responses without actually purchasing labels. Only switch to production keys when you are ready to ship real packages.
- Store both the EasyPost shipment ID and the carrier tracking number. If a tracking webhook references a tracking number you cannot find in your database, you can look up the EasyPost shipment by its ID as a fallback.
- Validate destination addresses before purchasing labels. EasyPost's address verification endpoint returns address corrections and error codes for invalid addresses. Catching bad addresses before label purchase saves the cost of undeliverable shipments.
- Add a unique constraint on tracking_number in the shipments table to prevent duplicate shipment rows if a webhook fires before the buy-label Edge Function finishes writing to the database.
- Log all carrier API call latencies (rate fetch time, label purchase time) to help identify which carrier integrations are slowest. EasyPost aggregates multiple carriers in one call, but individual carrier connections within EasyPost can timeout independently.
- For high-volume shipping, add a job queue for label generation instead of synchronous Edge Function calls. Use Supabase Edge Function with a Postgres NOTIFY/LISTEN queue pattern so label generation happens asynchronously and the user sees a 'Processing' state.
- Archive labels in Supabase Storage with user_id as the first path segment for security. Generate signed URLs with a short expiry (5 minutes) for downloads rather than storing permanent links.

## Frequently asked questions

### Why use EasyPost instead of integrating with each carrier's API directly?

Each carrier (UPS, FedEx, USPS, DHL) has different authentication systems, request/response formats, and documentation quality. EasyPost normalizes all of them behind one API. You write one integration and get access to 100+ carriers. For a Lovable app, EasyPost reduces the number of Edge Functions and API keys to manage from 4+ down to 1.

### How do I get an EasyPost API key?

Create a free account at easypost.com. In your account dashboard, go to API Keys to find your test and production keys. The test key lets you simulate shipments without paying for labels — use it while building. Switch to the production key when you are ready to ship real packages. Store the key in Lovable's Cloud tab → Secrets as EASYPOST_API_KEY.

### How do I register my webhook URL with EasyPost?

After deploying the tracking-webhook Edge Function (by clicking Publish in Lovable), copy the Edge Function URL from Supabase Cloud tab → Edge Functions. In EasyPost dashboard → Webhooks, add a new webhook with this URL and select event types: tracker.created and tracker.updated. EasyPost will POST tracking events to this URL automatically.

### What happens if my Supabase Edge Function is down when a carrier sends a webhook?

EasyPost retries failed webhook deliveries multiple times over 24 hours with exponential backoff. If your Edge Function returns a non-200 response or times out, EasyPost queues the retry. This means you will not lose tracking events for transient outages. Always return a 200 response quickly — do processing asynchronously if needed.

### Can I use this integration for international shipping?

Yes. EasyPost supports international shipping carriers. For international shipments, add customs_info and customs_items to the EasyPost shipment request body — this includes the customs form data required for cross-border packages. EasyPost generates the customs documentation alongside the shipping label. Add customs fields to the new shipment form for international destinations.

### How much does EasyPost cost?

EasyPost charges a per-label fee on top of the carrier's base rate. The fee varies by carrier: typically $0.01–$0.05 per label. USPS labels may actually be cheaper through EasyPost than buying directly due to commercial pricing. EasyPost also offers free address verification credits and free tracking webhooks. Check easypost.com/pricing for current rates.

### Can RapidDev help build a more complex shipping and fulfillment system?

Yes. RapidDev builds production shipping integrations including order management, warehouse pick-pack workflows, multi-location inventory, and carrier SLA monitoring. Reach out if your shipping requirements go beyond a single-dashboard integration.

### How do I handle refunds for cancelled shipments?

EasyPost supports label refunds via a POST to /v2/refunds with the tracking code. Add a 'Void Label' button in the shipment DropdownMenu that calls an Edge Function wrapping the EasyPost refund endpoint. Refunds are typically processed in 3–5 business days. Update the shipment status to 'cancelled' and store the refund ID from EasyPost's response in a refund_id column on the shipments table.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/shipping-integration
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/shipping-integration
