Documentation

Build a marketplace

A two-sided marketplace — requesters post jobs, runners accept and complete them — using the dfw_errand pattern.

A marketplace has two faces. One person posts a request; another person fulfills it. Underneath, the platform matches them, takes payment when the work is done, and tracks everything in case something goes wrong. This tutorial walks through the pattern used by dfw_errand — a delivery/errand marketplace — and shows the AppEngine endpoints holding it together.

What you're building

  • Requester app: customers post errands (delivery, pickup, task), see status, pay on completion.
  • Runner app: runners (drivers, gig workers) browse open requests, accept, complete, and get paid.
  • Backend: AppEngine handles the job collection, the assignment workflow, payments, and the tracking feed.

The model is the same whether you're shipping food, dispatching cleaners, or matching freelancers to gigs. The collections change names; the mechanics don't.

The data model

Three custom collections do the heavy lifting:

CollectionWhat it holds
delivery (or job, task)The unit of work. Status field drives the lifecycle.
runnerProfile + status of someone who can accept jobs.
delivery-eventAppend-only log of state transitions, location pings, and chat events.

Customers and runners both authenticate via the Customer principal, with different customerGroups (requesters vs runners). Permissions on each collection are scoped by group.

You can implement the same pattern with built-in collections like order and task. Custom collections give you a clean schema and easier permissions; built-ins give you more turnkey features (returns, refunds). dfw_errand uses custom; this tutorial follows that.

Lifecycle

posted → matched → accepted → in-progress → completed → paid
                         ↓
                       cancelled

State transitions flip the state field on the delivery record. Each transition fires an event the AppEngine workflow engine can listen for.

Prerequisites

  • AppEngine instance, org id
  • Two Next.js apps (requester + runner) — or one app with conditional UI
  • Stripe Connect set up for runner payouts (see Stripe payouts — TBD)
  • Twilio for runner notifications

Step-by-step

  1. 1

    Define the delivery collection

    In the admin UI, open Collections → New collection. Name it delivery. The schema:

    {
      "fields": {
        "title": { "type": "string", "required": true },
        "description": { "type": "string" },
        "pickup": { "type": "object", "fields": { "address": "string", "lat": "number", "lng": "number" } },
        "dropoff": { "type": "object", "fields": { "address": "string", "lat": "number", "lng": "number" } },
        "items": { "type": "array" },
        "scheduledFor": { "type": "datetime" },
        "price": { "type": "number" },
        "tip": { "type": "number" },
        "state": {
          "type": "string",
          "enum": ["posted", "matched", "accepted", "in-progress", "completed", "paid", "cancelled"],
          "default": "posted"
        },
        "requesterId": { "type": "string", "required": true },
        "runnerId": { "type": "string" },
        "paymentIntentId": { "type": "string" }
      }
    }
    

    Permissions: requesters can create and read-own; runners can read (filtered to state=posted or runnerId=self) and update (only their own); both can read events.

  2. 2

    Requester posts a delivery

    The requester app uses the generic repository-api.ts (copy from base-app/src/lib/) plus a thin wrapper.

    // src/lib/delivery-api.ts
    import { getAppEngineClient } from '@/lib/appmint-client';
    
    export async function createDelivery(data: NewDelivery) {
      const client = getAppEngineClient();
      return client.processRequest('post', 'data/delivery', { data });
    }
    
    export async function listMyDeliveries(state?: string) {
      const params = state ? `?filter=state:${state}` : '';
      return getAppEngineClient().processRequest('get', `data/delivery${params}`);
    }
    

    The data/{collection} endpoints are the universal CRUD machinery — they apply to every collection, custom or built-in. See Repository API.

  3. 3

    Match runners to the request

    When a delivery is created, AppEngine fires a delivery.created event. A workflow listens for it and runs the matching logic.

    In Automation → Workflows, create a workflow:

    • Trigger: delivery.created
    • Steps:
      1. Query: find runners where state=available and within 5 km of pickup. Use the geo-radius operator on the runner's lastLocation field.
      2. Send notification: notification.send to each runner with the delivery preview.
      3. Wait: 30 seconds. Check if any runner accepted (delivery.state == 'accepted').
      4. Loop: if no acceptance, expand radius to 10 km, retry; eventually mark the delivery unmatched.

    Workflows replace what would otherwise be a job queue and a matching service in your own code. Define them in the UI and they run on AppEngine's BullMQ workers.

  4. 4

    Runner accepts the job

    Runner app polls or subscribes to the delivery collection filtered to state=posted. Accepting a delivery is a write that flips state and stamps the runner id:

    // src/lib/runner-api.ts
    export async function acceptDelivery(deliveryId: string) {
      const client = getAppEngineClient();
      return client.processRequest('patch', `data/delivery/${deliveryId}`, {
        data: { state: 'accepted', runnerId: 'self' },
      });
    }
    

    runnerId: 'self' is a server-side directive — AppEngine substitutes the authenticated customer's id. This prevents a runner from claiming a job for someone else.

    The delivery.accepted event fires. The matching workflow stops trying. A new workflow ("notify requester") runs, pinging the requester via SMS or push.

  5. 5

    Track the runner in real time

    When the runner is in transit, you want the requester's app to show a live map. Two options:

    • Cheap and reliable: runner app posts a location every 15 seconds to data/delivery-event with type=location. Requester subscribes to that collection over WebSocket (socket.on('delivery-event:created')).
    • Built for this: AppEngine's realtime gateway. Both clients open a Socket.IO connection scoped to the delivery id; the runner emits location:update, the requester gets it.

    dfw_errand uses option two, via the existing chat WebSocket — same gateway, different room. From the runner client:

    import { io } from 'socket.io-client';
    
    const socket = io(process.env.NEXT_PUBLIC_APPENGINE_URL!, {
      auth: { token, orgId },
    });
    
    navigator.geolocation.watchPosition((pos) => {
      socket.emit('delivery:location', {
        deliveryId,
        lat: pos.coords.latitude,
        lng: pos.coords.longitude,
      });
    });
    

    On the requester side, socket.on('delivery:location', ({ lat, lng }) => updateMap(lat, lng)). Persist a sample of these to delivery-event so the trip can be replayed.

  6. 6

    Take payment on completion

    When the runner marks the delivery completed, fire a workflow that captures the payment. The pattern: create the Stripe PaymentIntent at posting time with capture_method: manual, then call AppEngine's storefront/stripe/capture when the runner confirms.

    // posting time — held funds
    const intent = await client.processRequest('post', 'storefront/stripe/intent', {
      amount: deliveryPrice * 100,
      currency: 'usd',
      capture_method: 'manual',
      metadata: { deliveryId, runnerId },
    });
    await client.processRequest('patch', `data/delivery/${deliveryId}`, {
      data: { paymentIntentId: intent.id },
    });
    
    // completion time — capture
    await client.processRequest('post', 'storefront/stripe/capture', {
      paymentIntentId: delivery.data.paymentIntentId,
    });
    

    For the runner payout, create a Stripe Connect transfer to the runner's connected account. AppEngine has a finance/payout endpoint that wraps this — use it instead of calling Stripe directly so finance records stay in sync.

  7. 7

    Handle disputes and cancellations

    Cancellations are state changes:

    await api.processRequest('patch', `data/delivery/${id}`, {
      data: { state: 'cancelled', cancelReason: 'customer-changed-mind' },
    });
    

    A delivery.cancelled workflow refunds the held intent (storefront/stripe/refund) and fires SMS to both parties.

    For disputes — work was done but the requester complains — AppEngine's CRM ticket module is the right place. Create a ticket linked to the delivery:

    await api.processRequest('post', 'data/ticket', {
      data: {
        subject: `Dispute: delivery ${deliveryId}`,
        deliveryId,
        customerId: requesterId,
        runnerId,
        state: 'open',
      },
    });
    

    Then a human agent (or AI assistant via the chat module) handles the conversation.

  8. 8

    Reviews and rating

    After completion, both parties can rate. Two writes to the built-in review collection:

    await api.processRequest('post', 'data/review', {
      data: {
        targetType: 'runner',
        targetId: runnerId,
        rating: 5,
        comment: 'Fast and friendly',
        deliveryId,
      },
    });
    

    A workflow on review.created updates the runner's averageRating field — denormalized so the matching query can sort by it.

Mobile clients

dfw_errand ships a Flutter app for both requesters and runners. The Flutter SDK uses the same endpoints; the lib structure under dfw_errand/lib/services/ mirrors what's described here:

  • api_service.dart — the AppEngine HTTP client
  • delivery_service.dart — delivery CRUD wrapper
  • location_service.dart — geolocation + WebSocket emitter
  • stripe_service.dart — payment handoff

If you want the same code on web and mobile, build the API layer in TypeScript first and port the contracts to Dart, not the other way around. AppEngine endpoint shapes don't change between clients.

Where to look in dfw_errand

  • lib/screens/customer/new_delivery_screen.dart — posting flow
  • lib/screens/driver/driver_jobs_screen.dart — runner job list
  • lib/screens/driver/job_detail_screen.dart — accept/in-progress UI
  • lib/screens/driver/driver_map_screen.dart — live tracking
  • lib/services/delivery_service.dart — the API wrapper

The web equivalents in base-app aren't pre-built but the lib pattern transfers directly. Most of what's in delivery_service.dart becomes a delivery-api.ts that imports appmint-client.ts.