<!-- Generated by scripts/build-ai-docs.ts from docs/convex.md. Do not edit. -->

# Use VeloShip from Convex

`@velopeplabs/ship-convex` is a [Convex component](https://docs.convex.dev/components) for
VeloShip. Your Convex functions quote, buy, batch and track FedEx, USPS and UPS labels, and every
shipment, tracker and batch is mirrored into the component's tables, so queries that read them stay
reactive. A designed version of this guide is at https://ship.vprev.dev/docs/convex/.

## How it works

You create one `VeloShip` instance and call it from your own queries, mutations and actions:

```
Your Convex app  --veloship.buy()-->   veloship component  --HTTPS, idempotent-->  VeloShip API
(queries, actions) <--reactive reads--  (mirror tables,     <--signed webhooks---   (FedEx, USPS, UPS)
                                         file storage)
```

- **Writes** (`createShipment`, `buy`, `createBatch`, `createReturn`, ...) call the API and upsert
  the result into the mirror.
- **Reads** (`getShipmentByReference`, `listShipments`, `getBatch`, ...) take a query context, read
  the mirror and are reactive. They never call the API.
- **Buying calls are safe to retry.** They carry idempotency keys derived from their arguments.
- **A webhook receiver** on your HTTP router verifies signatures, drops redeliveries, and never lets
  an older event overwrite newer state.
- **Labels** are downloaded once into Convex file storage.
- **Test mode** makes the mock carriers fail on demand.

## Install

```sh
npm install @velopeplabs/ship-convex convex
```

The component ships its generated code, so there is no codegen to run for it.

Mount the component (the peer dependency is `convex` 1.43 or newer):

```ts
// 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;
```

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

```sh
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` |

```ts
// 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);
```

A component can't read your environment, so `VeloShip` reads the variables inside your own
functions, at call time, and never hands them to the component. 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`; `protection:manage` for claims; and `print:write` for
`veloship.print`.

## Quickstart: quote, buy, track

Methods that call the API run in **actions**; the read methods run in **queries** and are reactive.

```ts
// 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:

```tsx
// 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>
  );
}
```

`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](#webhooks) is registered. Without it, call
`syncShipment` and `refreshTracker` yourself.

## Webhooks

Mount the receiver on your HTTP router. `onEvent` is optional: an internal mutation that runs after
the mirror write, in the same transaction.

```ts
// 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;
```

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.

```ts
// 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` }),
});
```

```sh
npx convex run orders:setupWebhook
```

The URL must be `https` and publicly reachable, so this works against a cloud deployment, not
`localhost`.

| 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`, `print.*`, `webhook.test` | Nothing; 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:

```ts
// 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" });
  },
});
```

## 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:

```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 }
```

| 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](#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".

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

```ts
// 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.

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.

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

```ts
// 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`.

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

```ts
// 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.

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

## Shipping rules

[Shipping rules](https://ship.vprev.dev/docs/md/rules.md) ("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.

```ts
// 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`).

## Printing

With a [print station](https://ship.vprev.dev/docs/md/api.md#printing) paired (a Mac or Linux computer with the printers, set up in
the admin under **Printing**), `veloship.print` sends labels, packing slips and documents straight to a
printer. These are plain API calls from an action; nothing is mirrored.

```ts
// convex/printing.ts
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { veloship } from "./veloship";

export const printOrder = internalAction({
  args: { orderId: v.string(), shipmentId: v.string(), slipPdfBase64: v.string() },
  handler: async (_ctx, { orderId, shipmentId, slipPdfBase64 }) => {
    await veloship.print.printLabel(shipmentId, { idempotency_key: `${orderId}-label` });
    await veloship.print.createJob({ file_base64: slipPdfBase64, kind: "slip", idempotency_key: `${orderId}-slip` });
  },
});
```

`createJob` routes by `kind` (`label`, `slip`, `packing_list`, `invoice`, `document`) unless you pass
`printer_id` or `role`. Without an `idempotency_key` it derives one from what it sends, so a retried
action prints once, and so does the same request sent again later: use `reprintJob` for another copy.
Also available: `getJob`, `listJobs`, `cancelJob`, `reprintJob`, `listPrinters`, `getPrinter` (with its `health`), `listStations`, `getStation`, `listLocations` and `printTestPage`.
Follow progress with the `print.job.done` and `print.job.failed` events in `onEvent`.

## 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`:

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

| 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](https://ship.vprev.dev/docs/md/errors.md).

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

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

| 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](https://ship.vprev.dev/docs/md/testing.md).

### Unit tests with convex-test

Register the component with `convex-test`, then stub the environment and `fetch` (or pass `fetch`
in the `VeloShip` options):

```ts
// 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", ...)`.

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

## Method reference

Methods of `new VeloShip(components.veloship, options?)`. **action** methods call the API and need
an action `ctx`; **query** methods read the mirror and work in queries, mutations and actions. Methods without `ctx` are
plain API calls that mirror nothing. Calls that take options accept
`{ idempotencyKey, signal, scenario }` last.

| Method | ctx | Returns |
| --- | --- | --- |
| `quote(params, opts?)` | none | `RateQuote`: `rates`, `excluded_rates`, `recommended_rate`, ... |
| `compareRates(params, opts?)` | none | `RateComparison`: what `createBatch` would buy |
| `createShipment(ctx, params, opts?)` | action | `Shipment`; buys when `service` is set |
| `buy(ctx, id, params, opts?)` | action | `Shipment` |
| `rerate(ctx, id, opts?)` | action | `Shipment` with fresh rates |
| `refund(ctx, id, opts?)` | action | `Shipment` |
| `reconcile(ctx, id, opts?)` | action | `Shipment` |
| `createReturn(ctx, id, params?, opts?)` | action | The return `Shipment` |
| `syncShipment(ctx, id)` | action | `Shipment` |
| `downloadLabel(ctx, id)` | action | `{ storageId, url }` |
| `createBatch(ctx, params, opts?)` | action | `Batch`, orders `pending` |
| `syncBatch(ctx, id)` | action | `Batch` |
| `downloadBatchLabels(ctx, id, { partial? })` | action | `{ storageId, url, contentType, count }` |
| `createTracker(ctx, code, { carrier? })` | action | `Tracker` |
| `refreshTracker(ctx, id, opts?)` | action | `Tracker` |
| `ensureWebhook(ctx, { url })` | action | `{ id, url, created }` |
| `registerRoutes(http, options?)` | none | Mounts the receiver; same as the exported `registerRoutes(http, component, options?)` |
| `getShipment`, `getShipmentByReference`, `listShipments` | query | `MirroredShipment` rows |
| `getTrackerByCode`, `getTrackerByShipment` | query | `MirroredTracker` or `null` |
| `getBatch`, `listBatches`, `getBatchItemByReference` | query | `MirroredBatch` rows and items |
| `getLabel`, `getBatchLabels` | query | The stored file, or `null` |
| `listCarriers()`, `listServices(carrier?)` | none | Carriers with `capabilities`; service codes and names |
| `createAddress(params)`, `getAddress(id)` | none | Saved addresses; `verify: true` adds `verification` |
| `getSettings()`, `updateSettings(params)` | none | Default from address, parcel presets, protection pricing |
| `createClaim`, `listClaims`, `getClaim`, `updateClaim` | none | Package protection claims |
| `rules.*` | none | The shipping rules API |
| `print.createJob`, `print.printLabel`, `print.getJob`, `print.listJobs`, `print.cancelJob`, `print.reprintJob`, `print.listPrinters`, `print.getPrinter`, `print.listStations`, `print.getStation`, `print.listLocations`, `print.printTestPage` | none | VeloShip Print |
| `client` | none | The underlying `VeloShipClient`, for everything else |
| `withScenario(name)`, `mode` | none | A copy that sends a scenario; the key's mode |

The package also exports `registerRoutes`, `VeloShipError`, `PurchaseUnknownError`,
`isPurchaseUnknown`, the mirror types and every API type (`Shipment`, `CreateBatchParams`, `Event`,
...). `@velopeplabs/ship-convex/test` registers the component with `convex-test`.

### Mirror tables

Component tables, not visible in your own schema: `shipments`, `trackers`, `batches`, `batchItems`,
`webhooks` (signing secrets stored by `ensureWebhook`) and `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.
