VeloShipDOCS

@velopeplabs/ship-convex

Shipping labels, reactive.

A Convex component for VeloShip. Quote, buy, batch and track FedEx, USPS and UPS labels from your Convex functions, and read every shipment, tracker and batch from tables that update as packages move.

npm install @velopeplabs/ship-convex convex
convex/shipping.ts
export const tracking = query({
  args: { orderId: v.string() },
  handler: async (ctx, { orderId }) => {
    const shipment = await veloship.getShipmentByReference(ctx, orderId);
    if (!shipment) return null;
    // Re-runs whenever a webhook updates the mirror.
    return veloship.getTrackerByShipment(ctx, shipment.apiId);
  },
});
useQuery(api.shipping.tracking) → in_transit
On this pageHow it works

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.

Your Convex app Queries, mutations, actions
  • convex/shipping.ts
  • convex/http.ts
  • useQuery in React
veloship component Mirror tables and file storage
  • shipments
  • trackers
  • batches
  • labels
VeloShip API Rates, labels, tracking
  • 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#

Terminal
npm install @velopeplabs/ship-convex convex

Mount the component in your app. Its tables, functions and file storage are isolated from yours.

convex/convex.config.ts
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.

Terminal
npx convex env set VELOSHIP_API_KEY sk_test_...
VariableRead byValue
VELOSHIP_API_KEYVeloShipsk_test_... or sk_live_...
VELOSHIP_API_URLVeloShipOptional. Defaults to https://ship.vprev.dev
VELOSHIP_WEBHOOK_SECRETregisterRoutesOptional. Only if you don't use ensureWebhook

Create the instance once and import it wherever you ship:

convex/veloship.ts
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.

convex/shipping.ts
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:

src/OrderTracking.tsx
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.

convex/http.ts
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.

convex/orders.ts
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` }),
});
Terminal
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

EventMirrored into
shipment.purchased, shipment.refunded, shipment.purchase_failedshipments
tracker.created, tracker.updatedtrackers
batch.completedbatches and batchItems
claim.created, claim.updated, alert.raised, webhook.testNothing; they only reach onEvent

What the receiver guarantees

  • Verified. It checks X-Webhook-Signature (HMAC-SHA256 of timestamp.body, five minute tolerance) against VELOSHIP_WEBHOOK_SECRET or the secret option, and every secret stored by ensureWebhook, 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 running onEvent again.
  • 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 get 200 { applied: false } and skip onEvent.
  • Atomic. If onEvent throws, the mirror write rolls back, the receiver answers 500, and the API redelivers.

A typical onEvent writes tracking back to your own orders:

convex/orders.ts
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:

convex/shipping.ts
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 }
MethodDoes
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.

  1. Preview, optionally. compareRates takes the same body as createBatch and shows what each order would buy and what One Rate saves, without buying.
  2. 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.
  3. Follow it. The API sends one event, batch.completed, when every order has bought or failed. For progress in between, poll syncBatch.
  4. Print. downloadBatchLabels stores the merged file (one 4x6 page per label, or ZPL) in file storage and returns its URL.
convex/orders.ts
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.

convex/tracking.ts
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.

convex/dashboard.ts
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.

ReadReturns
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? } on quote, createShipment, compareRates and each batch order. Nothing in order goes to a carrier.
  • Send apply_rules: false to skip rules for one request.
  • Results are stored on the mirror row as appliedRules, excludedRates, recommendedRate, tags and notes.

The rules API itself is veloship.rules. Test mode and live mode keep separate rules.

convex/rules.ts
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, refund and createReturn fold in the mirrored updatedAt, 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.
  • createShipment and createBatch have no earlier state. If one was rejected and you fixed the cause, pass a new idempotencyKey.

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:

convex/shipping.ts
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.

CodeWhat to do
CARRIER.ERROR (422)The carrier rejected it; fix the address or package. The shipment stays created and buyable
CARRIER.UNAVAILABLE, RATE_LIMITEDTransient. The client already retried with backoff; retry later with the same key
SHIPMENT.RATES.EXPIREDrerate, then buy
RULES.BLOCKED, RULES.NO_RATE, RULES.SERVICE_EXCLUDEDA shipping rule decided; details names it
BATCH.IN_PROGRESSWait for batch.completed, or pass partial: true
IDEMPOTENCY.MISMATCHYou reused an idempotencyKey with a different body
FORBIDDEN.SCOPEThe 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.

convex/veloship.ts
// 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" });
ScenarioTry it with
no_rates, rates_slow, carrier_down, carrier_rate_limitedquote, and every call that quotes
purchase_timeout, purchase_rejected, charge_mismatchcreateShipment with service, buy
void_rejectedrefund
delivered_fast, exception_then_delivered, return_to_sender, lostThe buying call, then refreshTracker
address_invalid, address_suite_missingcreateAddress 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):

convex/shipping.test.ts
/// <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_KEY to the live key on the production deployment (its environment variables in the Convex dashboard, or npx convex env set against it).
  • Run ensureWebhook again 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.
ReferenceQuotes

Method reference

Methods of new VeloShip(components.veloship, options?). Calls that take options accept { idempotencyKey, signal, scenario } last.

  • actionCalls the API and mirrors the result. Needs an action ctx.
  • queryReads the mirror. Works in queries (re-running on change), mutations and actions.
  • no ctxA plain API call. Nothing is mirrored.

Quotes#

Price a package without creating anything. Plain API calls: nothing is mirrored, so they work anywhere.

quote(params, opts?)no ctx

Rates from every enabled carrier for one package, with the shipping rules applied.

ReturnsRateQuote: rates, excluded_rates, recommended_rate

compareRates(params, opts?)no ctx

Takes the same body as createBatch and shows what each order would buy, without buying.

ReturnsRateComparison

Shipments#

Create and manage labels. Every call writes the result to the shipments mirror, so queries that read it re-run.

createShipment(ctx, params, opts?)action

Creates a shipment and quotes it. Buys in the same call when service is set.

ReturnsShipment

buy(ctx, id, params, opts?)action

Buys a rate on an existing shipment. Safe to retry: the idempotency key comes from the arguments.

ReturnsShipment

rerate(ctx, id, opts?)action

Fresh rates for a shipment that hasn't been bought yet.

ReturnsShipment with new rates

refund(ctx, id, opts?)action

Voids a bought label. Returns the stored result if it was already refunded.

ReturnsShipment

reconcile(ctx, id, opts?)action

Resolves a purchase_unknown shipment: finds out whether the label was bought, so you never buy it twice.

ReturnsShipment

createReturn(ctx, id, params?, opts?)action

The return label for a shipment, with the addresses swapped. Mirrors it with returnOf set.

ReturnsThe return Shipment

syncShipment(ctx, id)action

Reads the shipment from the API and refreshes its mirror row.

ReturnsShipment

downloadLabel(ctx, id)action

Stores the label in the component's file storage once and returns a URL to it.

Returns{ storageId, url }

Batches#

Buy up to 100 labels at once in the background, then print them as one file.

createBatch(ctx, params, opts?)action

Starts a batch. Returns at once with every order pending.

ReturnsBatch

syncBatch(ctx, id)action

Refreshes the batch and its items from the API, for progress between webhooks.

ReturnsBatch

downloadBatchLabels(ctx, id, { partial? })action

Merges the batch's labels into one file in storage: one 4x6 page per label, or ZPL.

Returns{ storageId, url, contentType, count }

Trackers#

Follow any tracking number, bought here or not. Webhooks keep the trackers mirror current.

createTracker(ctx, code, { carrier? })action

Starts tracking a number. carrier defaults to the one the number's format points to.

ReturnsTracker

refreshTracker(ctx, id, opts?)action

Fetches the latest scans now and refreshes the mirror row.

ReturnsTracker

Webhooks#

How changes flow back into your tables: a signed receiver on your HTTP router.

ensureWebhook(ctx, { url })action

Registers your receiver URL with VeloShip once and stores its signing secret.

Returns{ id, url, created }

registerRoutes(http, options?)no ctx

Mounts the webhook receiver on your http.ts router. Also exported as registerRoutes(http, component, options?).

ReturnsNothing

Reads#

Reactive reads of the mirror. Use them in your own queries; they re-run whenever a webhook lands.

getShipment·getShipmentByReference·listShipmentsquery

One shipment by id or by your own reference, or a page of them.

ReturnsMirroredShipment rows

getTrackerByCode·getTrackerByShipmentquery

The tracker for a tracking number or a shipment.

ReturnsMirroredTracker or null

getBatch·listBatches·getBatchItemByReferencequery

A batch, a page of batches, or one order inside a batch.

ReturnsMirroredBatch rows and items

getLabel·getBatchLabelsquery

The stored label file for a shipment or a batch.

ReturnsThe stored file, or null

Other API calls#

Plain calls straight to the VeloShip API. Nothing is mirrored.

listCarriers()·listServices(carrier?)no ctx

The carriers you can ship with and their service codes.

ReturnsCarriers with capabilities; services

createAddress(params)·getAddress(id)no ctx

Saved addresses. verify: true checks them with the carrier.

ReturnsAddress with verification

getSettings()·updateSettings(params)no ctx

Default from address, parcel presets and protection pricing.

ReturnsSettings

createClaim·listClaims·getClaim·updateClaimno ctx

Package protection claims.

ReturnsClaim

rules.*no ctx

The shipping rules API: list, create, retrieve, update, delete, reorder, test, draft, copyToLive.

ReturnsRules

clientno ctx

The underlying VeloShipClient, for anything not listed here.

ReturnsVeloShipClient

withScenario(name)·modeno ctx

A copy that sends a test scenario with every call; the key's mode.

ReturnsVeloShip; "test" | "live"

Exports#

Besides the VeloShip class, the package exports:

  • registerRoutes
  • VeloShipError
  • PurchaseUnknownError
  • isPurchaseUnknown
  • MirroredShipment
  • MirroredTracker
  • MirroredBatch
  • Shipment
  • CreateBatchParams
  • Event

…and every other API type. @velopeplabs/ship-convex/test registers the component with convex-test.

Mirror tables#

The component's own tables. They don't appear in your schema; read them through the methods above.

shipments
Every shipment, with its label and rates
trackers
Tracking status and scan history
batches
Batches and their progress
batchItems
One row per order in a batch
webhooks
Signing secrets stored by ensureWebhook
events
Applied event ids, pruned after three days

Labels live in the component's file storage. Releases only add optional fields, tables and indexes, so upgrading needs no migration.