Tutorial · Fulfillment

HioBuy Forwarding: Run a Parcel Forwarding Platform on the Fulfillment API

An open-source, self-hosted parcel forwarding app on Cloudflare Workers. Run it locally, keep the HioBuy key on the server, deploy it, and see how pre-alerts, consolidation, HioBuy Pay, tracking, and webhooks map to the HioBuy Fulfillment API.

HHioBuy Developer Team•13 min read•

HioBuy Forwarding: pre-alert, consolidate, pay and track on the HioBuy Fulfillment API

HioBuy Forwarding: Run a Parcel Forwarding Platform on the Fulfillment API

A parcel forwarding business needs more than a shipping calculator. It needs customer accounts, a personal warehouse address for each customer, package pre-alerts, status sync with the warehouse, consolidation, quotes that include your markup, customer payments, freight payment, and tracking.

HioBuy Forwarding is an open-source (Apache-2.0), self-hosted application that covers all of that. It runs on Cloudflare Workers. The physical work (receiving, storage, packing, international dispatch) is done by HioBuy warehouses through the HioBuy Fulfillment API.

You own the customer relationship, the brand, the selling price, and customer payments. HioBuy receives, stores, packs, and ships the parcels.

This tutorial covers:

  1. What the app does and what the public demo shows.
  2. How to run it locally and configure it, with the API key kept on the server.
  3. How to deploy it to Cloudflare.
  4. How each step maps to HioBuy Fulfillment API calls and webhooks.
  5. Common errors and how to read them.

What you are building

HioBuy Forwarding is a customer business platform. It is not a warehouse management system. It never weighs, packs, or ships anything itself.

It has three surfaces:

  • Public website with a shipping calculator (can be turned off with PUBLIC_SITE_MODE=app-only if you already have a marketing site).
  • Customer portal: warehouse address with member code, pre-alerts (up to 30 at a time), package photos from the warehouse, consolidation with a live quote, checkout from a USD wallet or Stripe Checkout, tracking, and documents.
  • Admin console: packages, shipments, customers, payments, pricing (one global markup, fixed fee, and exchange rate), destination switches, a read-only view of your HioBuy fulfillment wallet, HioBuy Pay, staff roles, email templates, and a developer guide at /admin/guide.

HioBuy Forwarding public website on the live demo

What the live demo shows

The public demo at forwarding.demo.hiobuy.com runs the same code with DEMO_MODE=false. You can browse the public site, use the calculator, and register a customer account.

The calculator on the demo is connected to HioBuy. A request goes from the browser to the demo's Worker, which calls POST /v1/fulfillment/shipping/quotes with its own key, then applies the operator markup and exchange rate before it returns a USD price. The channel it returns is a HioBuy testing channel, and the demo pricing is not a commercial offer. As the repository README says, balances, prices, and transactions on the demo are not real financial or logistics records. Do not send real parcels to an address shown on the demo.

Shipping calculator on the live demo returning a USD estimate

The read-only Demo Admin at /admin/demo uses sample data, sends no real email, and refuses HioBuy Pay. It only opens when a deployment sets DEMO_MODE=true. It is turned off on the public demo, so you will see it only on your own local or test deployment.

Customer registration on the live demo

How the workflow works

The customer-facing flow, as implemented in the repository:

Register → member code + warehouse address
    ↓
Pre-alert domestic tracking numbers   → HioBuy inbound notice (PENDING)
    ↓
Warehouse receives the parcel         → RECEIVED (pull or webhook), weight, photos
    ↓
Customer picks packages + address     → live quote, your markup, USD price
    ↓
Shipment created at HioBuy            → packages CONSOLIDATED, shipment PENDING
    ↓
Warehouse packs, final charge         → WAIT_PAYMENT
    ↓
Customer pays you (wallet or Stripe)
    ↓
Your team pays HioBuy (HioBuy Pay)    → WAIT_SHIP
    ↓
Dispatch and tracking                 → SHIPPED → SIGNED

Customer payment and HioBuy payment are separate. A customer can have paid you while HioBuy still shows payment_status: UNPAID. That is expected, and it does not mean the shipment is ready to leave.

Architecture

Browser (React)
  → Hono API on Cloudflare Workers  (/api/*)
    → domain rules (src/domain) and services (server)
      → Cloudflare D1, R2
      → server/services/hiobuy  → HioBuy Fulfillment API (https://api.hiobuy.com)

Three rules in the code base are worth copying into any HioBuy integration:

  • The browser only calls this app. The HioBuy API key stays in the Worker. Settings responses return only the last 4 characters of the key.
  • Prices are computed on the server. Eligibility, prices, payable amounts, and profit are calculated server-side. Prices or discounts sent by a client are never trusted, and guest or customer responses never include cost or markup.
  • Every HioBuy call lives in one folder: server/services/hiobuy. Pricing and profit rules live in src/domain.

Stack: React 19, TypeScript, Vite, Tailwind CSS, Hono, Cloudflare Workers, D1 with Drizzle ORM, R2, Better Auth, Stripe, Zod, Vitest. The optional signed-in assistant uses Cloudflare Agents, Durable Objects, and Workers AI.

Prerequisites

  • Node.js 20 or newer, and npm.
  • A Cloudflare account (for deployment).
  • A HioBuy developer app in HioBuy warehouse fulfillment mode, with a warehouse developer code bound in the portal, and a Production key (hio_live_*). See Fulfillment setup.

Warehouse fulfillment APIs need a paid plan: Growth ($99/month, 8,000 units/day) or Enterprise ($299/month, 50,000 units/day). Starter does not include fulfillment APIs. Daily units are one shared quota across 1688, Taobao, and Weidian, not a separate allowance per marketplace. See Rate limits.

For testing against a real warehouse configuration without live outbound, HioBuy assigns a WH-DEV code. The repository expects a hio_live_* key with that code, not a Sandbox hio_test_* key: Sandbox fixtures do not match the WH-DEV warehouse. See the WH-DEV testing guide.

Step 1: Run it locally

git clone https://github.com/hiobuy/forwarding.git
cd forwarding
npm install
cp .dev.vars.example .dev.vars
openssl rand -base64 32   # paste the result into BETTER_AUTH_SECRET in .dev.vars
npm run dev

npm run dev applies the local D1 migrations, then serves the pages and the API. Open the URL printed in the terminal.

Then:

  1. Register with the email you put in ADMIN_EMAILS. Without SMTP, the verification link appears on the verify page (development only).
  2. Open /admin. That account becomes the first owner.
  3. Go to Settings → Integration, save your HioBuy API key, then sync the warehouse and destinations.
  4. Set Settings → Pricing and enable countries in Settings → Destinations.
  5. Try a quote at /app/shipping-calculator.

Until the warehouse sync succeeds, customers see no warehouse address. The app never shows placeholder data as a live HioBuy address.

Other useful scripts:

CommandWhat it does
npm run testVitest over src/domain and server tests
npm run typecheckTypeScript project check
npm run db:resetWipe and recreate local D1
npm run db:generateGenerate a Drizzle migration after a schema change

Step 2: Configure the environment

Local values go in .dev.vars (ignored by Git). Production values are set with wrangler secret put. The important variables:

VariableRequiredPurpose
BETTER_AUTH_SECRETYesSession signing, and the key used to encrypt secrets saved from the admin
ADMIN_EMAILSFirst runPromotes the first owner. After that, Settings → Team manages staff
HIOBUY_API_TOKENNoFallback HioBuy key. A key saved in Settings → Integration wins
PUBLIC_SITE_MODENointegrated (default) or app-only
DEMO_MODENotrue opens the read-only Demo Admin. Keep false for a real business
SMTP_*, GOOGLE_*, TURNSTILE_*NoOverride the matching admin settings

Stripe keys, the bank account, the HioBuy webhook signing secret, and email templates are configured in the admin and stored in D1.

Keep the HioBuy key on the server

When you save a key in Settings → Integration, the server checks it against both GET /v1/fulfillment/locations (warehouse address) and GET /v1/fulfillment/shipping/channels?include=regions (destinations) before it keeps the key. One success is not enough.

The README lists one trade-off: a key saved from the admin is stored unencrypted in D1. If that matters to you, set the key as a Worker secret instead and leave the admin field empty:

npx wrangler secret put HIOBUY_API_TOKEN --env production

Either way, the key never reaches the browser. Do not add HioBuy calls to React code.

Step 3: Deploy to Cloudflare

The repository ships a wrangler.jsonc with a D1 database, an R2 bucket for photos, a Durable Object for the assistant, a Workers AI binding, and a Cron Trigger that runs every minute.

  1. Create a D1 database and an R2 bucket named hiobuy-forwarding-photos.

  2. In wrangler.jsonc, set your own database_id and route in the env.production block. The values in the repository belong to the HioBuy demo (forwarding.demo.hiobuy.com) and will not work in your account.

  3. Set secrets:

    npx wrangler secret put BETTER_AUTH_SECRET --env production
    npx wrangler secret put ADMIN_EMAILS --env production
    npx wrangler secret put HIOBUY_API_TOKEN --env production   # optional, see above
    
  4. Apply migrations and deploy:

    npm run db:migrate:remote
    npm run deploy
    
  5. In the admin, configure Integration, Destinations, Pricing, Email, and Payments.

  6. Point the HioBuy webhook at https://<your-domain>/api/webhooks/hiobuy and the Stripe webhook at https://<your-domain>/api/payments/stripe/webhook.

Auth callbacks, email links, and Stripe return URLs are built from the request origin, so serve the app from the domain your customers will see. Keep ENVIRONMENT=production and DEMO_MODE=false on a real deployment, and work through the repository's production checklist before you accept payments.

Admin overview from the repository README (sample data)

Step 4: How the app maps to the HioBuy Fulfillment API

All calls go from the Worker to https://api.hiobuy.com with Authorization: Bearer <key>. The warehouse developer code is never sent in requests. The HioBuy gateway attaches it based on your app's binding.

App stepHioBuy endpointNotes
Connect key, sync addressGET /v1/fulfillment/locationsCustomer warehouse address comes only from this sync
Sync destinationsGET /v1/fulfillment/shipping/channels?include=regionsPaged, page_size max 50
Calculator and consolidation quotePOST /v1/fulfillment/shipping/quotesDeclared-weight estimate; final charge comes after packing
Cargo typesGET /v1/fulfillment/shipments/item-attributesFeeds the calculator's cargo selector
Pre-alertPOST /v1/fulfillment/inbounds/createWith Idempotency-Key
Cancel pre-alertDELETE /v1/fulfillment/inbounds/{package_id}Only while waiting for receipt
Package statusGET /v1/fulfillment/packages, GET /v1/fulfillment/packages/{package_id}Manual pull or scheduled pull
ConsolidatePOST /v1/fulfillment/shipments/createWith Idempotency-Key
Shipment statusGET /v1/fulfillment/shipments, GET /v1/fulfillment/shipments/{shipment_id}
HioBuy PayPOST /v1/fulfillment/shipments/{shipment_id}/payCharges your fulfillment wallet
Cancel / interceptPOST .../{shipment_id}/cancel, POST .../{shipment_id}/interceptCancel before outbound, intercept after
TrackingGET /v1/fulfillment/shipments/{shipment_id}/trackingCarrier scan events
Wallet (read-only)GET /v1/fulfillment/balance, GET /v1/fulfillment/transactionsTop up in the HioBuy portal

Writes do not happen inside the customer's HTTP request. They are queued as sync tasks (inbound_create, shipment_create, shipment_pay, and others). The Cron Trigger processes due tasks every minute, and staff can trigger a sync from the admin. Each attempt is logged on the package or shipment with secrets redacted.

4.1 Inbound pre-alert

When a customer pre-alerts a parcel, the app creates an inbound_create task. The request body built by inboundNoticeBody() looks like this:

POST /v1/fulfillment/inbounds/create
Idempotency-Key: <one key per logical notice>

{
  "tracking_number": "YT1234567890",
  "carrier": "YTO",
  "package_count": 1,
  "total_value": 25.00,
  "currency": "USD",
  "external_order_id": "pkg_<local id>",
  "items": [
    { "name": "Parcel", "quantity": "1", "unit_price": "25.00", "currency": "USD" }
  ]
}

This matches the inbound notice docs: items[].quantity and unit_price are strings, total_value is a face amount (not fen), and currency may be CNY, USD, or KRW. The response returns a package_id (pkg_*). There is no separate inbound id. The package stays PENDING until the warehouse receives it.

The local reference is prefixed (pkg_…, and shp_… for shipments). The docs recommend this: numeric-only path values are reserved for HioBuy internal IDs.

On a 409 (INBOUND_TRACKING_ALREADY_EXISTS, EXTERNAL_ORDER_ID_ALREADY_EXISTS, IDEMPOTENCY_CONFLICT), error.details.existing_resource can carry the existing package. If that package matches the app's own reference and tracking number, the app links it instead of creating a second notice. Otherwise the task goes to Needs review.

4.2 Package received

Package status follows the canonical lifecycle from the docs:

PENDING → RECEIVED → CONSOLIDATED → SHIPPED → DELIVERED
side states: TRANSFERRING, DISCARDED, UNCLAIMED, ARCHIVED

Weight, dimensions, and photos arrive by pull (GET /v1/fulfillment/packages) or by the package.received webhook. Package condition (normal or exception) is tracked separately from the lifecycle status.

4.3 Consolidation and shipment create

The customer chooses received packages and an address. The server quotes the channels, adds your markup and fixed fee, converts to USD, and stores the quote. It then queues shipment_create. That task waits until every package has a HioBuy package_id with remote status RECEIVED, a channel code, and a complete address:

POST /v1/fulfillment/shipments/create
Idempotency-Key: <one key per logical shipment>

{
  "tracking_numbers": ["YT1234567890", "SF2026082001"],
  "shipping_channel_code": "<code from channels or quotes>",
  "receiver": {
    "name": "Alex Morgan",
    "mobile": "+12025550123",
    "country_code": "US",
    "postal_code": "10001",
    "province": "New York",
    "city": "New York",
    "address_line1": "123 Example Ave, Apt 4B"
  },
  "external_shipment_id": "shp_<local id>",
  "remark": "optional packing note"
}

Two details match the shipments docs:

  • tracking_numbers[] are the inbound domestic tracking numbers, not warehouse record IDs or procurement order numbers.
  • The district is merged into address_line1, because the create API does not forward a separate receiver.district.

The response returns the shipment id (the warehouse numeric ID as a string), order_sn, and usually status: PENDING. The app marks the packages CONSOLIDATED.

4.4 Ready for payment, then HioBuy Pay

Shipment lifecycle on HioBuy:

PENDING → WAIT_PAYMENT → WAIT_SHIP → SHIPPED → SIGNED
cancellation: CANCELLED

After the warehouse packs the shipment, it moves to WAIT_PAYMENT with a payable amount (shipment.ready_for_payment webhook, or a status pull). The app then bills the customer in USD.

An admin confirms HioBuy Pay only when all of these are true:

  • The customer has paid this app.
  • The remote status is WAIT_PAYMENT.
  • Your HioBuy fulfillment wallet covers the CNY amount.
  • If two-factor confirmation is on, the staff member entered an authenticator code.

The app calls POST /v1/fulfillment/shipments/{shipment_id}/pay with an Idempotency-Key. A successful payment moves the shipment to WAIT_SHIP. Per the docs, retries with the same key return the same result and do not charge twice. After paying, the app reads the shipment detail and stores the confirmed cost for the profit report. HioBuy amounts are in fen (monetary_unit: CNY_minor).

4.5 Dispatch and tracking

shipment.dispatched (or a status pull) moves the shipment to SHIPPED and stores the international tracking number. Carrier scan events come from GET /v1/fulfillment/shipments/{shipment_id}/tracking. shipment.delivered or a pull showing SIGNED closes it.

Cancel works while the shipment is PENDING or WAIT_PAYMENT. After outbound starts, use intercept. Interception has its own status (REQUESTED → PROCESSING → INTERCEPTED / FAILED) and never overwrites the shipment lifecycle status.

4.6 Webhooks

The endpoint POST /api/webhooks/hiobuy verifies the HioBuy-Signature header (t=<unix>,v1=<hex>) the way the webhooks docs describe: HMAC-SHA256 over "{t}.{raw_body}" with your whsec_… secret, a constant-time comparison, and a 5-minute timestamp tolerance. A missing secret or a bad signature returns 401 and stores nothing. Save the signing secret in the admin's fulfillment sync panel.

Deliveries are at-least-once, so the handler deduplicates on the event id. A repeated id that was already processed returns 200 without being applied again. A failed one can be processed again.

Events the app applies:

EventEffect in the app
package.receivedPackage → RECEIVED, customer notified
package.exceptionPackage flagged for the customer to respond
shipment.ready_for_paymentShipment → WAIT_PAYMENT, customer billed
shipment.dispatchedShipment → SHIPPED, tracking number stored
shipment.deliveredShipment delivered
shipment.exceptionSent to Needs review
balance.lowOperator alert to top up the fulfillment wallet

Other catalog events (shipment.created, consolidation.completed, procurement.*) are acknowledged and not used. The docs note that live warehouse-to-developer push is being rolled out progressively, so use Send test in the Developer Portal to check your receiver. The app keeps manual and scheduled pulls for this reason: a webhook does not replace a pull.

Important considerations

  • Two ledgers. Customers hold a USD wallet in your app. You hold a CNY fulfillment wallet at HioBuy. The app reads the HioBuy balance but cannot top it up.
  • Quotes are estimates. Calculator and consolidation quotes use declared weight and size. The final charge is set after the warehouse packs the shipment.
  • Rate limiting. The public calculator has a built-in limit, but it is per Worker isolate, not global. Add Cloudflare rate limiting rules in front of the calculator and auth routes.
  • Not included yet. Customers cannot request value-added services from the portal (staff can record one locally), commercial customs invoices are not generated, and procurement orders (/v1/orders/*) and returns have no client in this version. HioBuy documents a value-added services catalog if you want to add it yourself.
  • Branding. The Apache-2.0 license does not grant rights to the HioBuy name or logo. Replace them with your own branding when you run your own business.

Common errors

ErrorLikely causeFix
401 INVALID_API_KEYMissing, revoked, or malformed keyCheck the key in the Developer Portal. Sync tasks pause instead of retrying a rejected key
403 FULFILLMENT_MODE_NOT_SUPPORTEDApp is in self-fulfillment modeSwitch the app to HioBuy warehouse mode in the portal
403 WAREHOUSE_AUTH_INVALIDWarehouse rejected developer authenticationBind the warehouse code HioBuy provides, then test the connection again
403 INSUFFICIENT_SCOPEKey lacks the scope for the route (for example shipment:create)Check the app's permissions in the portal
No warehouse address for customersWarehouse sync has not succeededSave the key in Settings → Integration and sync again
409 INBOUND_TRACKING_ALREADY_EXISTSThat tracking number was already pre-alertedThe app links the existing package when it matches; otherwise check Needs review
409 PACKAGE_ALREADY_CONSOLIDATEDA parcel is already in another shipmentReview the shipment. The app sends it to Needs review
404 PARCEL_NOT_FOUNDA tracking number is wrong or not yet receivedWait for RECEIVED, or fix the tracking number
409 SHIPMENT_NOT_READY_FOR_PAYMENTThe warehouse has not finished packingWait for WAIT_PAYMENT
402 INSUFFICIENT_BALANCEFulfillment wallet too low on payTop up in the HioBuy portal. The customer is not charged again
429 RATE_LIMIT_EXCEEDED / QUOTA_EXCEEDEDPer-minute limit, or daily units used upBack off and retry. Cache data where your rules allow
Webhook returns 401No signing secret saved, or a wrong oneSave the whsec_… secret in the fulfillment sync panel

Always log request_id (or the x-request-id header) when you contact HioBuy support.

Next steps

  1. Open the live demo and try the calculator.
  2. Fork hiobuy/forwarding, run it locally, and connect a WH-DEV key.
  3. Walk one parcel through pre-alert, receive, consolidate, pay, and tracking before you change pricing or branding.
  4. Read the HioBuy Fulfillment Integration Guide and the webhooks demo for more detail on each stage.

API reference: hiobuy.com/en/api-docs.


Next step

If you already understand the workflow, move to the API reference for exact request and response fields, or open the developer console to create your application.

Ready to build?

Open the API documentation or create your HioBuy developer application.

Related guides