npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@velopeplabs/ship-convex

v0.4.0

Published

Convex component for VeloShip: quote, buy, batch and track FedEx, USPS and UPS labels from Convex functions, with reactive mirror tables and a signed webhook receiver.

Readme

@velopeplabs/ship-convex

A Convex component for VeloShip, the shipping API for FedEx, USPS and UPS. Your Convex backend can quote, buy, batch, return and track labels, and the state of every shipment, tracker and batch is mirrored into Convex tables, so your queries stay reactive.

Guide: Use VeloShip from Convex walks through setup, a quote-buy-track quickstart, webhooks, batches, testing and going live.

  • Writes (createShipment, buy, createBatch, createReturn, ...) call the API through @velopeplabs/ship and upsert the result into the mirror.
  • Buying calls are safe to retry. They carry idempotency keys derived from their arguments, and an unconfirmed purchase (SHIPMENT.PURCHASE_UNKNOWN) is reconciled, never bought again.
  • A webhook receiver verifies signatures, drops redeliveries, and never lets an older event overwrite newer state.
  • Batches, the end-of-day flow: buy up to 100 labels at once, follow progress reactively, and keep the merged labels PDF in Convex file storage.
  • Test mode makes the mock carriers fail on demand, so you can exercise your error handling.

Contents: Install · Environment · Setup · Methods · Batches · Shipping rules · Webhooks · Test mode · Retries · Errors · Mirror tables · Testing your app · FAQ · Development

Install

npm install @velopeplabs/ship-convex convex
// 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 component ships its generated code, so you don't run codegen for it.

Environment variables

Set these on your Convex deployment (npx convex env set ...). A component can't read your environment, so the VeloShip class reads them inside your own functions, and never hands them to the component.

| Variable | Used by | | | --- | --- | --- | | 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 |

Each can also be passed in code: new VeloShip(components.veloship, { apiKey, baseUrl }) and registerRoutes(http, components.veloship, { secret }). Use a sk_test_ key in development and CI: test mode has its own data and mock carriers, so nothing is bought.

The key needs scopes for what you call: shipments:write covers quoting, shipments, batches and trackers; webhooks:manage for ensureWebhook; rules:read / rules:write for the rules helpers; settings:write for updateSettings and protection:manage for claims.

Setup

// convex/veloship.ts
import { VeloShip } from "@velopeplabs/ship-convex";
import { components } from "./_generated/api";

export const veloship = new VeloShip(components.veloship);
// 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", // default
  onEvent: internal.orders.onShippingEvent, // optional
});

export default http;

Register the webhook once per deployment and mode. ensureWebhook creates it through the API and stores the returned signing secret inside the component, so nothing needs copying:

export const setupWebhook = internalAction({
  args: {},
  handler: async (ctx) =>
    veloship.ensureWebhook(ctx, { url: `${process.env.CONVEX_SITE_URL}/veloship/webhook` }),
});
npx convex run orders:setupWebhook

The URL must be https and publicly reachable, so this works against a cloud deployment, not localhost. Calling it again is a no-op while the webhook exists and its secret is stored.

Methods

Methods that call the API take an action context (ctx) and mirror what they get back. The read methods take a query context and are reactive. Pass { idempotencyKey, signal, scenario } as the last argument of any call that takes options.

Quotes

| Method | | | --- | --- | | quote(params, opts?) | Rates without a shipment. Nothing is mirrored | | compareRates(params, opts?) | Previews what createBatch would buy for the same body, without buying |

const { rates, excluded_rates, recommended_rate } = await veloship.quote({
  to_address: customer,
  parcel: { preset: "small_box", weight: 24 },
  order: { value: "149.00", shipping_method: "Expedited" }, // for shipping rules
  carrier: "ups", // optional: only this carrier, or `carrier_account_id`
  sort: "cheapest",
});
for (const rate of rates) console.log(rate.carrier, rate.service, rate.rate, rate.carrier_account_id);

Shipments

| Method | | | --- | --- | | createShipment(ctx, params, opts?) | Pass service to buy in the same call; protection, order, apply_rules, carrier all pass through | | buy(ctx, shipmentId, { rate_id \| service, carrier?, carrier_account_id?, label_format? }) | | | rerate(ctx, shipmentId) | Fresh rates for a created shipment | | refund(ctx, shipmentId) | Voids the label | | reconcile(ctx, shipmentId) | Resolves purchase_unknown | | createReturn(ctx, shipmentId, { service?, label_format?, options? }) | Return label, addresses swapped. Mirrors it with returnOf set and refreshes the original | | syncShipment(ctx, shipmentId) | Fetches and refreshes the mirror | | downloadLabel(ctx, shipmentId) | Stores the label in file storage once; returns { storageId, url } | | downloadForm(ctx, shipmentId, formType) | The same for a customs form (commercial_invoice, cn22, cn23) |

const shipment = await veloship.createShipment(ctx, {
  reference: orderId,
  to_address: customer,
  parcel: "small_box",
  service: "FEDEX_GROUND",
  protection: { value: "149.00" }, // package protection; fee fixed at creation
});
const label = await veloship.downloadLabel(ctx, shipment.id);

The 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.

International. A parcel that crosses a customs border needs customs_info, with EasyPost's field names. It passes through quote, createShipment and each order of createBatch. The bought shipment's customs_info and forms (the customs documents, such as a commercial invoice) are stored on the mirror as customsInfo and forms.

const shipment = await veloship.createShipment(ctx, {
  reference: orderId,
  to_address: { name: "Ada Lovelace", street1: "10 Downing St", city: "London", zip: "SW1A 2AA", country: "GB", phone: "442071234567" },
  parcel: { weight: 16 },
  service: "FEDEX_INTERNATIONAL_PRIORITY",
  options: { duties_payer: "sender" }, // default `recipient`
  customs_info: {
    contents_type: "merchandise",
    customs_certify: true,
    customs_signer: "Ada Lovelace",
    customs_items: [{ description: "Cotton T-shirt", quantity: 2, value: "40.00", weight: 8, hs_tariff_number: "6109.10" }],
  },
});
for (const form of shipment.forms) {
  // Already filed electronically when `submitted_electronically`; print only for a paper copy.
  if (!form.submitted_electronically) await veloship.downloadForm(ctx, shipment.id, form.form_type);
}

listCarriers() reports where each carrier ships under capabilities.international.

Batches

| Method | | | --- | --- | | createBatch(ctx, params, opts?) | Up to 100 orders, bought in the background. Mirrors the batch with every order pending | | syncBatch(ctx, batchId) | Fetches the batch and refreshes the mirror | | downloadBatchLabels(ctx, batchId, { partial? }) | The merged labels file in file storage; see the walkthrough |

Trackers

| Method | | | --- | --- | | createTracker(ctx, trackingCode, { carrier? }) | carrier defaults to the one the number's format points to | | refreshTracker(ctx, trackerId) | |

Carriers, addresses, settings, claims, rules

Thin pass-throughs. Nothing is mirrored.

| Method | | | --- | --- | | listCarriers() | Each carrier with its capabilities | | listServices(carrier?) | [{ carrier, services: [{ code, name }] }]; filter by id (ups) or name (UPS) | | createAddress(params) / getAddress(id) | Saved addresses; verify: true adds verification and warnings. Pass the id as to_address: { id } | | getSettings() / updateSettings(params) | Default from-address, parcel presets, protection pricing | | createClaim(shipmentId, { reason, description? }), listClaims(params?), getClaim(id), updateClaim(id, params) | Package protection claims. Customer-filed claims arrive as claim.created events (source: "customer") | | rules.list(), .create(), .retrieve(), .update(), .delete(), .reorder(), .test(), .draft(), .copyToLive() | The rules API |

veloship.client is the underlying VeloShipClient, for anything else the API offers (API keys, reports, drafts, billing). The mirror doesn't see those calls.

Reads (reactive)

| Method | | | --- | --- | | getShipment(ctx, apiId) | | | getShipmentByReference(ctx, reference, mode?) | Newest match; references aren't unique | | listShipments(ctx, { status?, mode?, carrier?, carrierAccountId?, reference?, paginationOpts }) | Newest first, paginated, every filter optional | | getTrackerByCode(ctx, trackingCode) / getTrackerByShipment(ctx, shipmentId) | | | getBatch(ctx, batchId) | The batch with its items, in the order sent | | listBatches(ctx, { status?, mode?, paginationOpts }) | Newest first, without items | | getBatchItemByReference(ctx, reference, mode?) | The newest batch order carrying an order id | | getLabel(ctx, shipmentId) / getForm(ctx, shipmentId, formType) / getBatchLabels(ctx, batchId) | { storageId, url } once downloaded |

export const tracking = query({
  args: { orderId: v.string() },
  handler: async (ctx, args) => {
    const shipment = await veloship.getShipmentByReference(ctx, args.orderId);
    return shipment && (await veloship.getTrackerByShipment(ctx, shipment.apiId));
  },
});

export const upsAwaitingPickup = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) =>
    veloship.listShipments(ctx, { status: "purchased", carrier: "UPS", mode: "live", paginationOpts: args.paginationOpts }),
});

carrier filters use the display name that appears on shipments (FedEx, UPS, USPS). A React client using useQuery(api.orders.tracking, { orderId }) updates as webhooks land.

Batches walkthrough

The flow a Shopify-style shop needs: an order is paid, it joins the day's batch, the labels are printed, tracking is written back to the order. The example app is a runnable version, with its own orders table and tests.

1. Preview, optionally. compareRates takes the same body as createBatch, shows what each order would buy, and what One Rate saves, without buying:

const body = {
  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,
  one_rate_packages: ["FEDEX_SMALL_BOX", "FEDEX_MEDIUM_BOX"], // what you stock
  shipments: orders.map((order) => ({
    reference: order.orderId,
    to_address: order.to,
    parcel: { weight: order.weightOz },
    order: { value: order.value, shipping_method: order.shippingMethod },
  })),
} satisfies CreateBatchParams;

const preview = await veloship.compareRates(body);
preview.summary; // { total, difference, failed, ... }

2. Create the batch. It returns at once; the labels are bought in the background.

const batch = await veloship.createBatch(ctx, body);
await ctx.runMutation(internal.orders.markBatched, { orderIds, batchId: batch.id });

Each order can override service, one_rate, max_delivery_days, one_rate_packages, carrier, carrier_account_id and apply_rules. The idempotency key comes from the body, so an action that runs twice for the same orders creates one batch.

3. Follow it. The component mirrors the batch and every order. The API sends one batch event, batch.completed, when every order has bought or failed, so that is when the mirror, and your onEvent callback, hear about the result. There are no per-order events. For progress in between, poll syncBatch, for instance from the scheduler:

export const pollBatch = internalAction({
  args: { batchId: v.string(), attempt: v.number() },
  handler: async (ctx, args) => {
    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 });
    }
  },
});
export const progress = query({
  args: { batchId: v.string() },
  handler: async (ctx, args) => veloship.getBatch(ctx, args.batchId),
});
// { status: "processing" | "completed" | "completed_with_errors", purchased, failed, pending, items: [...] }

Snapshots never go backwards: a slow poll that returns after the webhook is ignored.

4. Write tracking back. In your onEvent mutation (it runs in the same transaction as the mirror write), match batch.completed items to orders by reference:

export const onShippingEvent = internalMutation({
  args: { event: v.any() },
  handler: async (ctx, { event }) => {
    if (event.type !== "batch.completed") return;
    for (const item of event.data.items) {
      if (item.status === "purchased") { /* save item.tracking_code, item.carrier, item.packaging */ }
      else if (item.status === "failed") { /* flag the order: item.error.message */ }
    }
  },
});

Failed orders don't stop the rest. Fix their address or package and put them in the next batch.

5. Print. downloadBatchLabels stores the merged file (one 4x6 page per label for PDF, or ZPL) in Convex file storage and returns a URL:

const { url, count } = await veloship.downloadBatchLabels(ctx, batchId);

It is stored once per set of labels: calling it again returns the same file. While the batch is still processing the API refuses (BATCH.IN_PROGRESS); pass { partial: true } for the labels bought so far, which replaces the stored file as more arrive. Print at 100% / actual size, not "fit to page", or barcodes can fail to scan.

6. Tracking after that. Each purchased order has a shipment and a tracker. With the webhook registered, shipment.purchased, tracker.created and tracker.updated events fill getShipmentByReference and getTrackerByCode. Without it, call syncShipment and refreshTracker yourself.

Shipping rules

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 only 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. Rules can read all of it. Nothing in order goes to a carrier.
  • Send apply_rules: false to skip rules for a request (on a batch, for every order that doesn't set its own).
  • Shipments store applied_rules, excluded_rates, recommended_rate, tags and notes on the mirror row (appliedRules, excludedRates, recommendedRate, tags, notes) and in data. A pick rule's choice is in recommended_rate.
  • A rule can refuse an order (RULES.BLOCKED) or leave no rate (RULES.NO_RATE), and rules beat an explicit service (RULES.SERVICE_EXCLUDED). See Error handling.

The rules API is exposed as veloship.rules, the client's rules object. Test mode and live mode keep separate rules.

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" }],
    }),
});

// A dry run: which rules match, what they'd do, and which rates survive.
const result = await veloship.rules.test({
  input: { to_address: customer, parcel: { weight: 16 }, order: { value: "149.00" } },
  quote: true,
});

await veloship.rules.reorder(["rule_b", "rule_a"]);
await veloship.rules.draft({ text: "Require a signature over $100" }); // unsaved draft; needs an AI key on the server

Webhooks

registerRoutes verifies X-Webhook-Signature (HMAC-SHA256 of timestamp.body, five minute tolerance), then applies the event in one mutation:

| Event | Mirrored | | --- | --- | | 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 reach onEvent |

  • Secrets. It accepts options.secret / VELOSHIP_WEBHOOK_SECRET and every secret stored by ensureWebhook, so test and live webhooks can share one URL.
  • Responses. 400 for a bad or stale signature or body, 500 if no secret is configured or the callback threw (the API redelivers), 200 otherwise with { applied }.
  • Redeliveries. Delivery is at least once. The component records each event id and acknowledges a repeat with 200 { applied: false, duplicate: true }, without running onEvent again. Ids are kept for three days, longer than the API retries.
  • Out of order. A shipment or tracker row is never overwritten by an object with an older updated_at. A batch row is never overwritten by a snapshot with fewer resolved orders, or by processing once it is completed. Stale events get 200 { applied: false } and skip onEvent.
  • onEvent is a mutation taking { event }. It runs in the same transaction as the mirror write and the event record, so if it throws, all of it rolls back and the redelivery runs again.

Test mode and scenarios

The API key decides the mode: sk_test_... keys act on test data with mock carriers, sk_live_... on live data. (A root key takes mode in the VeloShip options and defaults to live.) Every mirrored row stores mode, so test and live data can sit in one deployment and be told apart:

await veloship.listShipments(ctx, { status: "purchased", mode: "live", paginationOpts });

Webhooks are per mode: run ensureWebhook once with a test key and once with a live key.

Scenarios make the mock carriers misbehave, to test failure handling. They send the VeloShip-Scenario header, and only with a test key: a live key never carries one, even if you set it. See Testing with scenarios for every scenario.

// Every call from this instance
const flaky = new VeloShip(components.veloship, { scenario: "carrier_down" });

// A copy for a test
const lost = veloship.withScenario("purchase_timeout");
await lost.buy(ctx, shipmentId, { service: "FEDEX_GROUND" }); // throws PurchaseUnknownError

// One call
await veloship.quote(params, { scenario: "carrier_down@ups" }); // UPS fails, the others answer

| 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 | Pass it to 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 scenario that only exists as a header is not stored with the shipment: for tracking scenarios, which need to survive later calls, put __scenario:<name> in to_address.street2 instead.

Retries, idempotency and lost purchases

Idempotency keys. Convex doesn't retry actions by itself, but your scheduler, workpool or users do. Every buying call (createShipment, buy, refund, createReturn, createBatch, createClaim) sends an Idempotency-Key derived from its arguments, so running the same call again replays the first result instead of buying twice. The API remembers keys for 24 hours.

  • Pass your own with { idempotencyKey: "shopify-order-1042" } when you have a natural key.
  • Calls on an existing shipment (buy, refund, createReturn) also fold in the mirrored updatedAt, so once the shipment has moved, a fixed-up retry gets a fresh attempt instead of a replayed rejection.
  • createShipment and createBatch have no earlier state to fold in. If one was rejected and you fixed the cause (a rule, a carrier account), pass a new idempotencyKey to try again.
  • buy on a shipment the mirror already shows as bought with that rate returns it without calling the API, so a retry after a late failure is a no-op. refund and createReturn do the same.

Unconfirmed purchases. If the carrier doesn't confirm a label, the API answers 502 SHIPMENT.PURCHASE_UNKNOWN and the shipment goes to purchase_unknown; a label may exist. Never buy the same package again. The component throws a PurchaseUnknownError (a VeloShipError with shipmentId) after refreshing the mirror, and the client never retries it:

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". The API also reconciles every 15 minutes.
}

Show these in your UI with listShipments(ctx, { status: "purchase_unknown" }).

Labels. downloadLabel, downloadForm and downloadBatchLabels store a file once. Concurrent downloads, and retries after a failure halfway, leave one file; a file that can't be attached to a row is deleted.

Error handling

API errors throw a VeloShipError, re-exported from this package:

import { VeloShipError } from "@velopeplabs/ship-convex";

try {
  await veloship.createShipment(ctx, params);
} catch (err) {
  if (err instanceof VeloShipError) {
    err.status;     // 422
    err.code;       // "CARRIER.ERROR", "RULES.BLOCKED", ...
    err.details;    // validation issues, carrier errors, the rule that fired
    err.shipmentId; // set when a create-and-buy made the shipment but the purchase failed
    err.requestId;  // quote it when reporting a problem
  }
}

| Code | What to do | | --- | --- | | SHIPMENT.PURCHASE_UNKNOWN | reconcile, don't re-buy (above) | | CARRIER.ERROR (422) | The carrier rejected it; fix the address or package. The shipment is created and buyable | | CARRIER.UNAVAILABLE, CARRIER.ERROR (502, 503), 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. apply_rules: false skips rules | | 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 |

When a write fails and the shipment id is known, the shipment is re-fetched into the mirror before the error propagates, so states like purchase_unknown show up reactively. A failed call never leaves a half-written mirror: the API is called first, then the mirror is written.

Mirror tables

Component tables, not visible in your own schema.

  • shipments: apiId, mode, reference, status, trackingCode, trackerId, updatedAt, labelStorageId, carrier, carrierAccountId, returnOf, tags, notes, appliedRules, excludedRates, recommendedRate, customsInfo, forms, the stored forms, and data (the full Shipment). Indexed by id, reference, status, mode and status, tracking code, and carrier and status.
  • trackers: apiId, mode, trackingCode, shipmentId, status, updatedAt, carrier, carrierAccountId, data.
  • batches: apiId, mode, status, total, purchased, failed, pending, createdAt, completedAt, labelFormat, and the stored labels file.
  • batchItems: one per order, with batchId, index, reference, status, shipmentId, trackingCode, carrier, service, rate, packaging, error and data.
  • webhooks: ids, URLs and signing secrets stored by ensureWebhook.
  • events: ids of applied webhook events, pruned after three days.

Files (labels, customs forms, merged batch labels) live in the component's file storage.

Upgrading. Releases only add optional fields, tables and indexes to these tables, and never rename or retype a field, so existing deployments keep their data. Rows written by an earlier version lack the newer fields (carrier, tags, ...) until the shipment is next written, by a webhook or syncShipment; data always holds the full object as it was received. Filtering by carrier therefore only finds rows written since. Nothing needs migrating.

Testing your app

import { convexTest } from "convex-test";
import veloship from "@velopeplabs/ship-convex/test";

const t = convexTest(schema, modules);
veloship.register(t); // or veloship.register(t, "myName")

In tests, stub fetch or pass fetch in the VeloShip options; the example's tests cover the batch flow, signed webhooks, redeliveries and out-of-order events that way. Against a real deployment, use a sk_test_ key and the scenarios.

FAQ

Do I need the webhook? Not to use the API. Without it the mirror only changes when you call syncShipment, syncBatch and refreshTracker, or when a write returns. The webhook is what keeps tracking and batch results current on their own.

Why is a call "stuck" replaying an error? The API replays a stored response, including a 4xx rejection, for 24 hours per idempotency key. See idempotency.

Can I use more than one carrier? Yes. Quotes cover every enabled carrier account; filter with carrier or carrier_account_id, and service: "cheapest" picks across them in a batch. Accounts are managed in the VeloShip admin or with veloship.client.carrierAccounts.

Can I call the API without mirroring? Yes: veloship.client is the plain client.

Does it work in queries? The read methods do. The write methods call the network, so they need an action. Run them from a mutation with ctx.scheduler.runAfter(0, internal.x.y, ...).

How do I get the label to a printer? downloadLabel and downloadBatchLabels return a Convex storage URL. Open it, or fetch it from your server and send it to the printer; thermal printers want ZPL (label_format: "ZPLII") and 4x6 paper at actual size.

What happened to getTracker? It is getTrackerByCode. listShipmentsByStatus in the component is now listShipments, with more filters.

Where are customer claims? Customers file claims on the tracking page; they arrive as claim.created events with source: "customer", handled in onEvent. Staff claims go through createClaim. The request log isn't wrapped: the API only serves it to the root key.

Development

bun install
bun run build:packages   # the tests import the built client
bun run --filter @velopeplabs/ship-convex test
bun run --filter @velopeplabs/ship-convex typecheck

After changing src/component, regenerate its bindings (src/component/_generated):

cd packages/convex
CONVEX_AGENT_MODE=anonymous npx convex dev --once   # first time only: a local deployment, no account
npx convex codegen --component-dir ./src/component && bun run build

Tests are named *.cvxtest.ts rather than *.test.ts so the repo-root Workers vitest run doesn't pick them up. src/client/harness.cvxtest.ts holds the mocked-fetch helpers the component tests share; src/client/component.cvxtest.ts runs against the component's own functions and schema.