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:
| Collection | What it holds |
|---|---|
delivery (or job, task) | The unit of work. Status field drives the lifecycle. |
runner | Profile + status of someone who can accept jobs. |
delivery-event | Append-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
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:
requesterscancreateandread-own;runnerscanread(filtered tostate=postedorrunnerId=self) andupdate(only their own); both canreadevents. - 2
Requester posts a delivery
The requester app uses the generic
repository-api.ts(copy frombase-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
Match runners to the request
When a delivery is created, AppEngine fires a
delivery.createdevent. A workflow listens for it and runs the matching logic.In Automation → Workflows, create a workflow:
- Trigger:
delivery.created - Steps:
- Query: find runners where
state=availableand within 5 km ofpickup. Use thegeo-radiusoperator on the runner'slastLocationfield. - Send notification:
notification.sendto each runner with the delivery preview. - Wait: 30 seconds. Check if any runner accepted (
delivery.state == 'accepted'). - Loop: if no acceptance, expand radius to 10 km, retry; eventually mark the delivery
unmatched.
- Query: find runners where
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.
- Trigger:
- 4
Runner accepts the job
Runner app polls or subscribes to the
deliverycollection filtered tostate=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.acceptedevent fires. The matching workflow stops trying. A new workflow ("notify requester") runs, pinging the requester via SMS or push. - 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-eventwithtype=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 todelivery-eventso the trip can be replayed. - Cheap and reliable: runner app posts a location every 15 seconds to
- 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 withcapture_method: manual, then call AppEngine'sstorefront/stripe/capturewhen 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/payoutendpoint that wraps this — use it instead of calling Stripe directly so finance records stay in sync. - 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.cancelledworkflow 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
ticketlinked 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
Reviews and rating
After completion, both parties can rate. Two writes to the built-in
reviewcollection:await api.processRequest('post', 'data/review', { data: { targetType: 'runner', targetId: runnerId, rating: 5, comment: 'Fast and friendly', deliveryId, }, });A workflow on
review.createdupdates the runner'saverageRatingfield — 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 clientdelivery_service.dart— delivery CRUD wrapperlocation_service.dart— geolocation + WebSocket emitterstripe_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 flowlib/screens/driver/driver_jobs_screen.dart— runner job listlib/screens/driver/job_detail_screen.dart— accept/in-progress UIlib/screens/driver/driver_map_screen.dart— live trackinglib/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.