Overview
How it works#
You create one VeloShip instance and call it from your own queries, mutations and actions. Writes go to the VeloShip API and the result is upserted into the component's tables. Webhooks flow back through a receiver on your HTTP router and update the same tables, so any query that reads them re-runs on its own.
- convex/shipping.ts
- convex/http.ts
- useQuery in React
- shipments
- trackers
- batches
- labels
- FedEx · USPS · UPS
- test-mode mocks
- shipping rules
Your functions call the component's methods. The component calls the VeloShip API, mirrors what comes back, and applies signed webhook events as they arrive. Your queries read the mirror.
- 01Reactive mirrorShipments, trackers and batches live in Convex tables. Read them from queries; React updates when a webhook lands.
- 02Safe to retryBuying calls derive idempotency keys from their arguments. A retried action replays the first result instead of buying twice.
- 03Webhook receiverVerifies signatures, drops redeliveries, and never lets an older event overwrite newer state.
- 04End-of-day batchesBuy up to 100 labels at once, follow progress reactively, and keep the merged 4x6 file in storage.
- 05Labels in storageEach label is downloaded once into Convex file storage and served from a storage URL.
- 06Test modeWith an
sk_test_key, mock carriers fail on demand so you can exercise every error path.
Step 1
Install#
npm install @velopeplabs/ship-convex convex
Mount the component in your app. Its tables, functions and file storage are isolated from yours.
import { defineApp } from "convex/server";
import veloship from "@velopeplabs/ship-convex/convex.config.js";
const app = defineApp();
app.use(veloship);
export default app;
The peer dependency is convex 1.43 or newer.
Step 2
Configure#
Set your API key on your Convex deployment. Start with a test key: test mode has its own data and mock carriers, so nothing is bought.
npx convex env set VELOSHIP_API_KEY sk_test_...
| Variable | Read by | Value |
|---|---|---|
VELOSHIP_API_KEY | VeloShip | sk_test_... or sk_live_... |
VELOSHIP_API_URL | VeloShip | Optional. Defaults to https://ship.vprev.dev |
VELOSHIP_WEBHOOK_SECRET | registerRoutes | Optional. Only if you don't use ensureWebhook |
Create the instance once and import it wherever you ship:
import { VeloShip } from "@velopeplabs/ship-convex";
import { components } from "./_generated/api";
// Reads VELOSHIP_API_KEY from this deployment's environment.
export const veloship = new VeloShip(components.veloship);
The component never sees your key. A component can't read your environment, so VeloShip reads the variables inside your own functions, at call time. You can also pass them in code: new VeloShip(components.veloship, { apiKey, baseUrl }). Other options are mode (root keys only), scenario, fetch, maxRetries and timeoutMs.
The key needs the scopes for what you call: shipments:write covers quoting, shipments, batches and trackers; webhooks:manage is for ensureWebhook; rules:read and rules:write for the rules helpers; settings:write for updateSettings; and protection:manage for claims.
Step 3
Quickstart: quote, buy, track#
Methods that call the API run in actions; the read methods run in queries and are reactive. This file quotes rates, buys a label and exposes its tracking to the client.
import { v } from "convex/values";
import { action, query } from "./_generated/server";
import { veloship } from "./veloship";
const address = v.object({
name: v.string(),
street1: v.string(),
city: v.string(),
state: v.string(),
zip: v.string(),
phone: v.string(),
});
// 1. Quote: rates across every enabled carrier. Nothing is stored.
export const rates = action({
args: { to: address, weightOz: v.number() },
handler: async (_ctx, args) => {
const { rates } = await veloship.quote({ to_address: args.to, parcel: { weight: args.weightOz } });
return rates.map((rate) => ({
carrier: rate.carrier,
service: rate.service,
name: rate.service_name,
price: rate.rate,
days: rate.delivery_days,
}));
},
});
// 2. Buy: create the shipment and buy the chosen service in one call, then store the label.
export const buyLabel = action({
args: { orderId: v.string(), to: address, weightOz: v.number(), service: v.string() },
handler: async (ctx, args) => {
const shipment = await veloship.createShipment(ctx, {
reference: args.orderId,
to_address: args.to,
parcel: { weight: args.weightOz },
service: args.service,
});
const label = await veloship.downloadLabel(ctx, shipment.id);
return { shipmentId: shipment.id, trackingCode: shipment.tracking_code, labelUrl: label.url };
},
});
// 3. Track: a reactive query over the mirror.
export const tracking = query({
args: { orderId: v.string() },
handler: async (ctx, args) => {
const shipment = await veloship.getShipmentByReference(ctx, args.orderId);
if (!shipment) return null;
const tracker = await veloship.getTrackerByShipment(ctx, shipment.apiId);
return {
shipmentStatus: shipment.status,
carrier: shipment.carrier ?? null,
trackingCode: shipment.trackingCode ?? null,
trackingStatus: tracker?.status ?? null,
estimatedDelivery: tracker?.data.est_delivery_date ?? null,
events: tracker?.data.tracking_details ?? [],
};
},
});
Weights are in ounces and money is a decimal string ("12.34"). service is a code such as FEDEX_GROUND, taken from a quoted rate. On the client, the tracking query updates by itself as events arrive:
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export function OrderTracking({ orderId }: { orderId: string }) {
const tracking = useQuery(api.shipping.tracking, { orderId });
if (tracking === undefined) return <p>Loading…</p>;
if (tracking === null) return <p>No label yet.</p>;
return (
<p>
{tracking.carrier} {tracking.trackingCode}: {tracking.trackingStatus ?? "label created"}
</p>
);
}
Where does it ship from? from_address is optional and defaults to the default from address in your VeloShip settings. Set it once in the admin, or with veloship.updateSettings({ default_from_address: { ... } }).
Tracking status only moves on its own once the webhook is registered. Without it, call syncShipment and refreshTracker yourself.
Keep it current
Webhooks#
Mount the receiver on your HTTP router. onEvent is optional: an internal mutation that runs after the mirror write, in the same transaction.
import { httpRouter } from "convex/server";
import { registerRoutes } from "@velopeplabs/ship-convex";
import { components, internal } from "./_generated/api";
const http = httpRouter();
registerRoutes(http, components.veloship, {
path: "/veloship/webhook", // the default
onEvent: internal.orders.onShippingEvent, // optional
});
export default http;
Then register the webhook once per deployment and mode. ensureWebhook creates it through the API and stores the signing secret inside the component, so there is nothing to copy. Calling it again is a no-op while the webhook exists and its secret is stored.
import { internalAction } from "./_generated/server";
import { veloship } from "./veloship";
export const setupWebhook = internalAction({
args: {},
handler: async (ctx) =>
veloship.ensureWebhook(ctx, { url: `${process.env.CONVEX_SITE_URL}/veloship/webhook` }),
});
npx convex run orders:setupWebhook
Use a cloud deployment. The URL must be https and publicly reachable, so this works against a cloud deployment, not localhost.
What each event updates
| Event | Mirrored into |
|---|---|
shipment.purchased, shipment.refunded, shipment.purchase_failed | shipments |
tracker.created, tracker.updated | trackers |
batch.completed | batches and batchItems |
claim.created, claim.updated, alert.raised, webhook.test | Nothing; they only reach onEvent |
What the receiver guarantees
- Verified. It checks
X-Webhook-Signature(HMAC-SHA256 oftimestamp.body, five minute tolerance) againstVELOSHIP_WEBHOOK_SECRETor thesecretoption, and every secret stored byensureWebhook, so test and live webhooks can share one URL. - Once. Delivery is at least once. Each event id is recorded for three days, and a repeat gets
200 { applied: false, duplicate: true }without runningonEventagain. - In order. A shipment or tracker is never overwritten by an object with an older
updated_at, and a batch never by a snapshot with fewer resolved orders. Stale events get200 { applied: false }and skiponEvent. - Atomic. If
onEventthrows, the mirror write rolls back, the receiver answers500, and the API redelivers.
A typical onEvent writes tracking back to your own orders:
import { v } from "convex/values";
import type { Event } from "@velopeplabs/ship-convex";
import { internalMutation } from "./_generated/server";
export const onShippingEvent = internalMutation({
args: { event: v.any() },
handler: async (ctx, args) => {
const event = args.event as Event;
if (event.type !== "tracker.updated" || event.data.status !== "delivered") return;
const order = await ctx.db
.query("orders")
.withIndex("by_tracking_code", (q) => q.eq("trackingCode", event.data.tracking_code))
.unique();
if (order) await ctx.db.patch("orders", order._id, { status: "delivered" });
},
});
Capabilities
Shipments and labels#
Every shipment method takes an action ctx, calls the API, and mirrors the result. Pass service to createShipment to buy in the same call, or leave it out to get rates and buy one later:
const shipment = await veloship.createShipment(ctx, {
reference: orderId,
to_address: customer,
parcel: { preset: "small_box", weight: 24 }, // a saved preset with the weight overridden
protection: { value: "149.00" }, // package protection; fee fixed at creation
});
const ground = shipment.rates.find((rate) => rate.service === "FEDEX_GROUND");
if (!ground) throw new Error("No Ground rate for this package");
const bought = await veloship.buy(ctx, shipment.id, { rate_id: ground.id, label_format: "ZPLII" });
const label = await veloship.downloadLabel(ctx, bought.id); // { storageId, url }
| Method | Does |
|---|---|
buy(ctx, id, params) | { rate_id }, or { service, carrier?, carrier_account_id? }, plus label_format? |
rerate(ctx, id) | Fresh rates for a created shipment, after SHIPMENT.RATES.EXPIRED |
refund(ctx, id) | Voids the label |
createReturn(ctx, id, params?) | A return label with the addresses swapped. Mirrored with returnOf set; the original is refreshed |
reconcile(ctx, id) | Resolves purchase_unknown (see Retries and errors) |
syncShipment(ctx, id) | Fetches the shipment and refreshes the mirror |
downloadLabel(ctx, id) | Stores the label in file storage once and returns { storageId, url } |
A shipment's carrier, carrier_account_id, tags, notes, applied_rules, excluded_rates, recommended_rate and order come back typed and are stored on the mirror row. Thermal printers want label_format: "ZPLII"; print PDFs at 100% on 4x6 paper, not "fit to page".
Capabilities
Batches#
The end-of-day flow: paid orders go into one batch of up to 100, the labels are bought in the background, printed as one file, and tracking is written back to each order.
- Preview, optionally.
compareRatestakes the same body ascreateBatchand shows what each order would buy and what One Rate saves, without buying. - Create the batch. It returns at once with every order
pending. The idempotency key comes from the body, so running the action twice for the same orders creates one batch. - Follow it. The API sends one event,
batch.completed, when every order has bought or failed. For progress in between, pollsyncBatch. - Print.
downloadBatchLabelsstores the merged file (one 4x6 page per label, or ZPL) in file storage and returns its URL.
import { v } from "convex/values";
import type { CreateBatchParams } from "@velopeplabs/ship-convex";
import { internal } from "./_generated/api";
import { action, internalAction, query } from "./_generated/server";
import { veloship } from "./veloship";
export const shipPaidOrders = action({
args: {},
handler: async (ctx): Promise<string | null> => {
const orders = await ctx.runQuery(internal.orders.paidOrders, {}); // your own query, up to 100
if (orders.length === 0) return null;
const body: CreateBatchParams = {
service: "cheapest", // or "fastest", "best_value", or a service code
one_rate: "compare", // buy in a FedEx box when that is cheaper
max_delivery_days: 5,
shipments: orders.map((order) => ({
reference: order.orderId,
to_address: order.to,
parcel: { weight: order.weightOz },
order: { value: order.value, shipping_method: order.shippingMethod },
})),
};
// Optional: `await veloship.compareRates(body)` previews the cost without buying.
const batch = await veloship.createBatch(ctx, body);
await ctx.scheduler.runAfter(60_000, internal.orders.pollBatch, { batchId: batch.id, attempt: 1 });
return batch.id;
},
});
// A fallback for when no webhook is registered; snapshots never go backwards.
export const pollBatch = internalAction({
args: { batchId: v.string(), attempt: v.number() },
handler: async (ctx, args): Promise<void> => {
const batch = await veloship.syncBatch(ctx, args.batchId);
if (batch.status === "processing" && args.attempt < 30) {
await ctx.scheduler.runAfter(60_000, internal.orders.pollBatch, { ...args, attempt: args.attempt + 1 });
}
},
});
// { status, purchased, failed, pending, items: [...] }, updated as the batch resolves.
export const progress = query({
args: { batchId: v.string() },
handler: async (ctx, args) => veloship.getBatch(ctx, args.batchId),
});
export const printLabels = action({
args: { batchId: v.string() },
handler: async (ctx, args) => {
const { url, count } = await veloship.downloadBatchLabels(ctx, args.batchId);
return { url, count };
},
});
Each order can override service, one_rate, max_delivery_days, one_rate_packages, carrier, carrier_account_id and apply_rules. In onEvent, match batch.completed items to your orders by reference: a purchased item has tracking_code, carrier and packaging; a failed one has error.message. Failed orders don't stop the rest. Fix them and put them in the next batch.
Printing early. While the batch is still processing the API refuses the labels file (BATCH.IN_PROGRESS). Pass { partial: true } for the labels bought so far; the stored file is replaced as more arrive. Otherwise the file is stored once, and calling again returns the same one.
Capabilities
Trackers#
Every label bought through VeloShip gets a tracker automatically. For tracking numbers from elsewhere, create one. carrier defaults to the one the number's format points to.
import { v } from "convex/values";
import { action, query } from "./_generated/server";
import { veloship } from "./veloship";
export const track = action({
args: { trackingCode: v.string() },
handler: async (ctx, args) => {
const tracker = await veloship.createTracker(ctx, args.trackingCode); // or { carrier: "ups" }
return tracker.id;
},
});
export const byCode = query({
args: { trackingCode: v.string() },
handler: async (ctx, args) => veloship.getTrackerByCode(ctx, args.trackingCode),
});
With the webhook registered, tracker.updated events keep the row current. Without it, call refreshTracker(ctx, trackerId). The row has status (pre_transit, in_transit, out_for_delivery, delivered, ...) and the full tracker in data, including est_delivery_date and tracking_details.
Capabilities
Reactive queries#
The read methods take a query ctx and read the component's tables, so they are reactive like any Convex query. They never call the API.
import { paginationOptsValidator } from "convex/server";
import { query } from "./_generated/server";
import { veloship } from "./veloship";
export const upsAwaitingPickup = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) =>
veloship.listShipments(ctx, {
status: "purchased",
carrier: "UPS",
mode: "live",
paginationOpts: args.paginationOpts,
}),
});
export const needsAttention = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) =>
veloship.listShipments(ctx, { status: "purchase_unknown", paginationOpts: args.paginationOpts }),
});
Use them with usePaginatedQuery on the client. listShipments is newest first and every filter is optional; carrier is the display name that appears on shipments (FedEx, UPS, USPS). Pass mode: "live" to keep test data out of live views.
| Read | Returns |
|---|---|
getShipment(ctx, id) | The mirrored shipment, or null |
getShipmentByReference(ctx, ref, mode?) | The newest match; references aren't unique |
listShipments(ctx, args) | Paginated; filters status, mode, carrier, carrierAccountId, reference |
getTrackerByCode(ctx, code) | The tracker for a tracking number |
getTrackerByShipment(ctx, id) | The tracker for a shipment |
getBatch(ctx, id) | The batch with its items, in the order sent |
listBatches(ctx, args) | Paginated, newest first, without items; filters status, mode |
getBatchItemByReference(ctx, ref, mode?) | The newest batch order carrying an order id |
getLabel(ctx, id), getBatchLabels(ctx, id) | { storageId, url } once downloaded |
A mirrored shipment row has apiId, mode, reference, status, trackingCode, trackerId, carrier, tags, appliedRules, recommendedRate and more, plus data: the full shipment as the API returned it.
Capabilities
Shipping rules#
Shipping rules ("orders over $100 need a signature", "never ship heavy parcels with USPS") run on the server on every quote, shipment and batch order. The component passes the inputs and keeps the results.
- Send
order: { value, currency?, tags?, shipping_method?, shipping_paid?, items? }onquote,createShipment,compareRatesand each batch order. Nothing inordergoes to a carrier. - Send
apply_rules: falseto skip rules for one request. - Results are stored on the mirror row as
appliedRules,excludedRates,recommendedRate,tagsandnotes.
The rules API itself is veloship.rules. Test mode and live mode keep separate rules.
import { internalAction } from "./_generated/server";
import { veloship } from "./veloship";
export const addSignatureRule = internalAction({
args: {},
handler: async () =>
veloship.rules.create({
name: "Signature over $100",
when: { all: [{ field: "order.value", op: "gt", value: "100.00" }] },
then: [{ type: "set_signature", signature: "direct" }],
}),
});
Also available: list, retrieve, update, delete, reorder, test (a dry run), draft (plain English to an unsaved rule) and copyToLive. A rule can refuse an order (RULES.BLOCKED), leave no rate (RULES.NO_RATE), or exclude an explicit service (RULES.SERVICE_EXCLUDED).
Reliability
Retries and errors#
Idempotency, built in
Your scheduler, a workpool or a user can run an action twice. Every buying call (createShipment, buy, refund, createReturn, createBatch, createClaim) sends an Idempotency-Key derived from its arguments, so the second run replays the first result. The API remembers keys for 24 hours.
- Pass your own with
{ idempotencyKey: "shopify-order-1042" }when you have a natural key. buy,refundandcreateReturnfold in the mirroredupdatedAt, so once a shipment has moved, a fixed-up retry gets a fresh attempt. They also return the mirrored result without calling the API when it is already done.createShipmentandcreateBatchhave no earlier state. If one was rejected and you fixed the cause, pass a newidempotencyKey.
Unconfirmed purchases
If the carrier doesn't confirm a label, the API answers 502 SHIPMENT.PURCHASE_UNKNOWN and the shipment goes to purchase_unknown. The component refreshes the mirror and throws a PurchaseUnknownError:
import { isPurchaseUnknown } from "@velopeplabs/ship-convex";
try {
await veloship.buy(ctx, shipmentId, { service: "FEDEX_GROUND" });
} catch (err) {
if (!isPurchaseUnknown(err)) throw err;
// Voids an orphaned label, or finds none, and returns the shipment to "created".
const shipment = await veloship.reconcile(ctx, err.shipmentId);
// Buy again only if shipment.status === "created".
}
Never buy the same package again after SHIPMENT.PURCHASE_UNKNOWN: a label may exist. Reconcile first. The API also reconciles every 15 minutes, and listShipments(ctx, { status: "purchase_unknown", ... }) shows the ones waiting.
Other errors
API errors throw a VeloShipError, re-exported from the package, with status, code, details, requestId and, when a create-and-buy made the shipment but the purchase failed, shipmentId. Before a failed write rethrows, the shipment is re-fetched into the mirror, so its state shows up reactively; the mirror is never half-written.
| Code | What to do |
|---|---|
CARRIER.ERROR (422) | The carrier rejected it; fix the address or package. The shipment stays created and buyable |
CARRIER.UNAVAILABLE, RATE_LIMITED | Transient. The client already retried with backoff; retry later with the same key |
SHIPMENT.RATES.EXPIRED | rerate, then buy |
RULES.BLOCKED, RULES.NO_RATE, RULES.SERVICE_EXCLUDED | A shipping rule decided; details names it |
BATCH.IN_PROGRESS | Wait for batch.completed, or pass partial: true |
IDEMPOTENCY.MISMATCH | You reused an idempotencyKey with a different body |
FORBIDDEN.SCOPE | The key lacks a scope; details lists the options |
Every code, with its HTTP status, is in the error reference.
Before launch
Testing#
The key decides the mode: sk_test_... keys act on test data with mock carriers, sk_live_... on live data. Every mirrored row stores mode, so both can sit in one deployment and be told apart.
Make the carriers fail on purpose
Scenarios send the VeloShip-Scenario header, and only with a test key: a live key never carries one, even if you set it.
// Every call from this instance
const flaky = new VeloShip(components.veloship, { scenario: "carrier_down" });
// A copy for one test
const lost = veloship.withScenario("purchase_timeout");
await lost.buy(ctx, shipmentId, { service: "FEDEX_GROUND" }); // throws PurchaseUnknownError
// One call: UPS fails, the other carriers answer
await veloship.quote(params, { scenario: "carrier_down@ups" });
| Scenario | Try it with |
|---|---|
no_rates, rates_slow, carrier_down, carrier_rate_limited | quote, and every call that quotes |
purchase_timeout, purchase_rejected, charge_mismatch | createShipment with service, buy |
void_rejected | refund |
delivered_fast, exception_then_delivered, return_to_sender, lost | The buying call, then refreshTracker |
address_invalid, address_suite_missing | createAddress with verify: true |
Add @fedex, @ups, @usps or @mockex to limit a scenario to one carrier. A header scenario isn't stored with the shipment, so for tracking scenarios that must survive later calls, put __scenario:delivered_fast in to_address.street2 instead. Every scenario is described in Testing with scenarios.
Unit tests with convex-test
Register the component with convex-test, then stub the environment and fetch (or pass fetch in the VeloShip options):
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { expect, test, vi } from "vitest";
import veloship from "@velopeplabs/ship-convex/test";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.*s");
test("buys a label", async () => {
vi.stubEnv("VELOSHIP_API_KEY", "sk_test_...");
vi.stubEnv("VELOSHIP_API_URL", "https://api.test");
vi.stubGlobal("fetch", vi.fn(/* answer like the API */));
const t = convexTest(schema, modules);
veloship.register(t); // or veloship.register(t, "myName")
const result = await t.action(api.shipping.buyLabel, { /* ... */ });
expect(result.trackingCode).toBeTruthy();
});
Webhooks can be tested the same way, by posting a signed body to t.fetch("/veloship/webhook", ...).
Production
Going live#
No code changes. Swap the key on your production deployment and register its webhook.
- A live key needs a verified email and a carrier account of your own. Create the
sk_live_...key in the VeloShip admin, with only the scopes you call. - Set
VELOSHIP_API_KEYto the live key on the production deployment (its environment variables in the Convex dashboard, ornpx convex env setagainst it). - Run
ensureWebhookagain there. Webhooks are per mode, so the live one is separate from the test one. - Filter live views with
mode: "live"if test rows share the deployment. - Test and live keep separate shipping rules. Copy the ones you tested to live in the admin, or create them with a live key.