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.
What you're building
Rather than integrating with UPS, FedEx, and USPS APIs individually (each with different authentication, request formats, and rate models), this build uses EasyPost as a single aggregation layer. EasyPost normalizes all carrier APIs behind one REST interface. One EasyPost API key gives you rate comparison across 100+ carriers, address validation, label generation, and a unified webhook for tracking events.
The carrier proxy Edge Function receives a rate request from the Lovable frontend and calls the EasyPost Shipment creation API, which returns a list of available rates sorted by price. The Edge Function caches the rate response in a Supabase rates table keyed by a hash of the origin address, destination address, parcel dimensions, and weight. Subsequent requests with identical parameters return the cached rates, saving API calls.
When a user selects a rate and clicks 'Buy Label', a second Edge Function call purchases the label via EasyPost's buy endpoint, receives a base64-encoded PDF, stores it in Supabase Storage, and creates a shipments row with the tracking number. EasyPost sends tracking webhook events to a public Edge Function URL. The webhook receiver verifies the event, parses the tracking status, and updates the shipment row and a tracking_events table.
The shipment dashboard lets warehouse staff see all shipments, filter by carrier and status, view the tracking timeline in a Sheet, and download the label PDF via a Supabase signed URL.
Final result
A complete multi-carrier shipping integration dashboard with rate comparison, label generation, tracking, and webhook-powered status updates.
Tech stack
Prerequisites
- 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
Build steps
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.
1Create a shipping integration schema in Supabase:231. Create a private Storage bucket named 'shipping-labels' with public: false, file size limit 5MB, allowed MIME types: application/pdf452. Database tables:6 - 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_at7 - 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_at8 - tracking_events: id, shipment_id, status (text), description (text), location (text), carrier_timestamp (timestamptz), received_at9 - rates_cache: id, cache_key (text, unique), rates_json (jsonb), origin_zip, destination_zip, weight_oz, cached_at, expires_at10 - parcels: id, user_id, label (text), length_in (numeric), width_in (numeric), height_in (numeric), weight_oz (numeric), is_default (bool default false)11123. RLS:13 - addresses: users own their rows14 - shipments: users own their rows15 - tracking_events: users can SELECT where shipment_id is in their shipments. Service role for INSERT.16 - rates_cache: service role only17 - parcels: users own their rows18194. Storage RLS for shipping-labels:20 - SELECT: auth.uid()::text = (storage.foldername(name))[1]21 - INSERT: auth.uid()::text = (storage.foldername(name))[1]2223Add indexes:24- CREATE INDEX idx_shipments_tracking ON shipments(tracking_number);25- CREATE INDEX idx_shipments_status ON shipments(user_id, status, created_at DESC);26- 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.
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.
1// supabase/functions/get-shipping-rates/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1314 const { from_address, to_address, parcel } = await req.json()1516 // Generate cache key from normalized inputs17 const cacheKey = btoa(JSON.stringify([18 from_address.zip, to_address.zip,19 parcel.weight, parcel.length, parcel.width, parcel.height20 ]))2122 const supabase = createClient(23 Deno.env.get('SUPABASE_URL') ?? '',24 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''25 )2627 // Check cache28 const { data: cached } = await supabase29 .from('rates_cache')30 .select('rates_json')31 .eq('cache_key', cacheKey)32 .gt('expires_at', new Date().toISOString())33 .single()3435 if (cached) {36 return new Response(JSON.stringify({ rates: cached.rates_json, cached: true }), { headers: corsHeaders })37 }3839 // Call EasyPost API40 const easypostRes = await fetch('https://api.easypost.com/v2/shipments', {41 method: 'POST',42 headers: {43 Authorization: `Basic ${btoa(Deno.env.get('EASYPOST_API_KEY') + ':')}`,44 'Content-Type': 'application/json',45 },46 body: JSON.stringify({47 shipment: {48 to_address,49 from_address,50 parcel: {51 weight: parcel.weight,52 length: parcel.length,53 width: parcel.width,54 height: parcel.height,55 },56 },57 }),58 })5960 const shipmentData = await easypostRes.json()6162 const rates = (shipmentData.rates ?? []).map((r: any) => ({63 id: r.id,64 carrier: r.carrier,65 service: r.service,66 rate: parseFloat(r.rate),67 currency: r.currency,68 delivery_days: r.delivery_days,69 delivery_date: r.delivery_date,70 easypost_shipment_id: shipmentData.id,71 }))7273 // Cache for 30 minutes74 const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString()75 await supabase.from('rates_cache').upsert({76 cache_key: cacheKey,77 rates_json: rates,78 origin_zip: from_address.zip,79 destination_zip: to_address.zip,80 weight_oz: parcel.weight,81 cached_at: new Date().toISOString(),82 expires_at: expiresAt,83 }, { onConflict: 'cache_key' })8485 return new Response(JSON.stringify({ rates, cached: false }), { headers: corsHeaders })86})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.
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.
1Create a Supabase Edge Function at supabase/functions/buy-label/index.ts.23Accept POST body: { rate_id: string, easypost_shipment_id: string, origin_address_id: string, destination_address_id: string }45Logic:61. Authenticate the user via the Authorization header (user-scoped Supabase client)72. Call EasyPost buy endpoint: POST https://api.easypost.com/v2/shipments/{easypost_shipment_id}/buy with body { rate: { id: rate_id } }83. From the response, extract: tracking_code, selected_rate (carrier, service, rate, currency), postage_label.label_url (PDF URL), tracker.id94. Fetch the PDF from label_url: const pdfRes = await fetch(label_url); const pdfBuffer = await pdfRes.arrayBuffer()105. Store the PDF in Supabase Storage at path: {user_id}/{tracking_code}.pdf using service role client116. 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_id127. Return { shipment_id, tracking_number, label_storage_path, carrier, rate_amount }1314Handle 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.
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.
1// supabase/functions/tracking-webhook/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const STATUS_MAP: Record<string, string> = {6 pre_transit: 'pre_transit',7 in_transit: 'in_transit',8 out_for_delivery: 'out_for_delivery',9 delivered: 'delivered',10 failure: 'failure',11 return_to_sender: 'cancelled',12 cancelled: 'cancelled',13 error: 'failure',14 unknown: 'in_transit',15}1617serve(async (req: Request) => {18 if (req.method !== 'POST') {19 return new Response('Method not allowed', { status: 405 })20 }2122 const body = await req.text()23 let event: any24 try {25 event = JSON.parse(body)26 } catch {27 return new Response('Invalid JSON', { status: 400 })28 }2930 // Only process tracker.updated events31 if (event.description !== 'tracker.updated') {32 return new Response('OK', { status: 200 })33 }3435 const tracker = event.result36 const trackingNumber = tracker?.tracking_code37 const easypostStatus = tracker?.status3839 if (!trackingNumber) return new Response('OK', { status: 200 })4041 const supabase = createClient(42 Deno.env.get('SUPABASE_URL') ?? '',43 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''44 )4546 const { data: shipment } = await supabase47 .from('shipments')48 .select('id')49 .eq('tracking_number', trackingNumber)50 .single()5152 if (!shipment) return new Response('OK', { status: 200 })5354 const normalizedStatus = STATUS_MAP[easypostStatus] ?? 'in_transit'5556 // Update shipment status57 await supabase58 .from('shipments')59 .update({ status: normalizedStatus, updated_at: new Date().toISOString() })60 .eq('id', shipment.id)6162 // Insert tracking events63 const events = (tracker.tracking_details ?? []).slice(-5)64 if (events.length > 0) {65 await supabase.from('tracking_events').insert(66 events.map((e: any) => ({67 shipment_id: shipment.id,68 status: e.status,69 description: e.message,70 location: [e.tracking_location?.city, e.tracking_location?.state].filter(Boolean).join(', '),71 carrier_timestamp: e.datetime,72 received_at: new Date().toISOString(),73 }))74 )75 }7677 return new Response('OK', { status: 200 })78})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.
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.
1Build the shipping dashboard at src/pages/ShippingDashboard.tsx and a rate comparison flow.231. Dashboard page:4- Stats row: total shipments today, in transit count, delivered today count, and average shipping cost5- 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, Actions6- Status Badge colors: pre_transit=gray, in_transit=blue, out_for_delivery=yellow, delivered=green, failure=red, cancelled=muted7- Actions DropdownMenu: View Tracking (opens Sheet), Download Label (calls Supabase storage.createSignedUrl for 5 min and triggers download)8- Filter row: status Select filter, carrier Select filter, date range Popover9102. New Shipment flow (multi-step Dialog or separate page):11- Step 1: address form (from/to). Show saved addresses as Select options. Allow new address entry.12- Step 2: parcel dimensions form (length, width, height in inches, weight in oz)13- 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.14- Step 4: confirm and buy - show summary, 'Buy Label' Button calls buy-label Edge Function. Show loading state.15163. Tracking Sheet (ShipmentTracking.tsx):17- Shows shipment header: tracking number, carrier, destination, current status Badge18- Vertical timeline of tracking_events ordered by carrier_timestamp DESC19- Each event: colored dot (green for delivered, yellow for in transit, red for failure), status label, description, location, timestamp20- 'Refresh' Button that re-fetches tracking_eventsExpected 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const STATUS_MAP: Record<string, string> = {5 pre_transit: 'pre_transit',6 in_transit: 'in_transit',7 out_for_delivery: 'out_for_delivery',8 delivered: 'delivered',9 failure: 'failure',10 return_to_sender: 'cancelled',11 cancelled: 'cancelled',12 error: 'failure',13 unknown: 'in_transit',14}1516serve(async (req: Request) => {17 if (req.method === 'OPTIONS') return new Response('ok')18 if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 })1920 let event: any21 try {22 event = await req.json()23 } catch {24 return new Response('Invalid JSON', { status: 400 })25 }2627 if (!['tracker.created', 'tracker.updated'].includes(event.description)) {28 return new Response('OK', { status: 200 })29 }3031 const tracker = event.result32 const trackingNumber = tracker?.tracking_code33 if (!trackingNumber) return new Response('OK', { status: 200 })3435 const supabase = createClient(36 Deno.env.get('SUPABASE_URL') ?? '',37 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''38 )3940 const { data: shipment } = await supabase41 .from('shipments')42 .select('id, status')43 .eq('tracking_number', trackingNumber)44 .single()4546 if (!shipment) return new Response('OK', { status: 200 })4748 const newStatus = STATUS_MAP[tracker.status] ?? 'in_transit'4950 if (shipment.status !== newStatus) {51 await supabase52 .from('shipments')53 .update({54 status: newStatus,55 updated_at: new Date().toISOString(),56 ...(newStatus === 'delivered' ? { actual_delivery_date: new Date().toISOString() } : {}),57 })58 .eq('id', shipment.id)59 }6061 const latestEvents = (tracker.tracking_details ?? []).slice(0, 10)62 if (latestEvents.length > 0) {63 // Use upsert based on carrier_timestamp to avoid duplicates64 await supabase.from('tracking_events').upsert(65 latestEvents.map((e: any) => ({66 shipment_id: shipment.id,67 status: e.status ?? newStatus,68 description: e.message ?? '',69 location: [70 e.tracking_location?.city,71 e.tracking_location?.state,72 e.tracking_location?.country,73 ].filter(Boolean).join(', '),74 carrier_timestamp: e.datetime ?? new Date().toISOString(),75 received_at: new Date().toISOString(),76 })),77 { onConflict: 'shipment_id,carrier_timestamp', ignoreDuplicates: true }78 )79 }8081 return new Response('OK', { status: 200 })82})Customization ideas
Bulk shipment creation from CSV
Add a Bulk Ship page where users upload a CSV with columns: recipient_name, street, city, state, zip, weight_oz. An Edge Function parses each row, calls get-shipping-rates with the user's default carrier preference, buys labels for all rows in sequence, and returns a summary ZIP file with all label PDFs. Show a progress indicator for large batches.
Shipping rules engine
Add a shipping_rules table where users configure automatic carrier selection rules: 'If weight < 16oz AND destination is domestic, always use USPS First Class'. When the rate comparison step runs, automatically pre-select the rate that matches the active rule. This saves time for operations teams shipping similar products daily.
Delivery exception alerts
Modify the tracking webhook to detect failure and return_to_sender events and send an email alert to the shipment creator via Resend. Include the tracking number, last known location, carrier contact number, and a link to the shipment details page. Add an alerts_paused setting per user to opt out of notifications.
Carrier performance analytics
Build a carrier analytics page using Recharts. Show average delivery days by carrier vs. estimated delivery days (accuracy rating), on-time delivery percentage by carrier and service type, cost per shipment over time, and failure rate by carrier. This data helps users choose the most reliable carrier for their routes.
Return label generation
Add a 'Generate Return Label' button on each delivered shipment. It calls EasyPost with the addresses swapped (original destination as origin, original origin as destination) and the same parcel dimensions. The return label is emailed to the recipient using Resend with a PDF attachment, or stored in Supabase Storage for the dashboard user to send manually.
Common pitfalls
Pitfall: Calling carrier APIs directly from the Lovable frontend
How to avoid: 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.
Pitfall: Not caching rate responses
How to avoid: 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.
Pitfall: Storing tracking webhook payloads in full without filtering
How to avoid: 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.
Pitfall: Not handling EasyPost rate expiry when buying labels
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a multi-carrier shipping integration using EasyPost API in a Deno Supabase Edge Function. I want to buy a shipping label, receive the label PDF as base64, and store it in Supabase Storage. Write the TypeScript code for: 1) calling EasyPost buy endpoint with a rate ID, 2) decoding the base64 label from the response, 3) uploading the decoded PDF bytes to Supabase Storage using the service role client. Show error handling for both the EasyPost API call and the Storage upload.
Add a shipment cost analytics section to the shipping dashboard. Show a Recharts LineChart with total shipping spend per day over the last 30 days. Below that, show a PieChart of spend breakdown by carrier. Add a 'Cost by Destination' table showing the top 10 destination zip codes ranked by total spend, with average cost per shipment. All metrics come from aggregating the shipments table (rate_amount, carrier, destination_address_id joined to addresses for zip code).
In Supabase, create an Edge Function that runs on a daily schedule via pg_cron to clean up the rates_cache table. The function should DELETE all rows WHERE expires_at < now(). Also check for shipments in 'pre_transit' status that are more than 7 days old with no tracking_events — these may be stuck. UPDATE their status to 'failure' and log the count. Return a summary JSON with rows cleaned from rates_cache and shipments updated to failure status.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation