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

# Shipping rules

Shipping rules let you write down how your shop ships, once, and have VeloShip apply it to every
quote, shipment and batch order. "Orders over $100 need a signature." "Nothing goes FedEx Ground to
Hawaii." "Light parcels go USPS, cheapest rate." Each rule is a sentence of the form **when this is
true, do that**, and you can build, test and switch rules on and off from the admin
without writing code.

This guide is for the person who runs the shop first, and for the developer wiring it up second.
Sections marked **Developers** go into the exact JSON and behavior.

- [A 60-second example](#a-60-second-example)
- [How rules work](#how-rules-work)
- [Who wins: precedence](#who-wins-precedence)
- [Conditions: the "when"](#conditions-the-when)
  - [Fields](#fields)
  - [Operators](#operators)
  - [Details that matter](#details-that-matter)
- [Actions: the "then"](#actions-the-then)
- [Picking a rate](#picking-a-rate)
- [Order details](#order-details)
- [Using rules from the API](#using-rules-from-the-api)
- [The admin page](#the-admin-page)
- [Testing rules](#testing-rules)
- [Recipes](#recipes)
- [Describe it in English](#describe-it-in-english)
- [Errors](#errors)
- [Limits and performance](#limits-and-performance)
- [FAQ and troubleshooting](#faq-and-troubleshooting)

Related: [API reference](https://ship.vprev.dev/docs/md/api.md#shipping-rules), admin guide,
[testing with scenarios](https://ship.vprev.dev/docs/md/testing.md#testing-shipping-rules), security.

---

## A 60-second example

**Goal:** orders worth more than $100 must be signed for.

**In the admin.** Open **More → Setup → Shipping rules**, press **Templates** and pick **Signature on orders over $100**
(or press **Add rule** and build it), read the sentence it shows, and save. Use **Try it** to see what
the rule does to a sample order before you rely on it.

**With the API.** Create the rule:

```sh
curl -X POST $BASE/v1/rules \
  -H "authorization: Bearer $API_KEY" -H "content-type: application/json" \
  -d '{
    "name": "Signature on orders over $100",
    "description": "Valuable orders must be signed for.",
    "when": { "all": [ { "field": "order.value", "op": "gt", "value": "100.00" } ] },
    "then": [ { "type": "set_signature", "signature": "direct" } ]
  }'
```

```json
{
  "id": "rule_01a1349006768453f51ebd736ab1",
  "object": "Rule",
  "name": "Signature on orders over $100",
  "description": "Valuable orders must be signed for.",
  "enabled": true,
  "position": 1,
  "when": { "all": [ { "field": "order.value", "op": "gt", "value": "100.00" } ] },
  "then": [ { "type": "set_signature", "signature": "direct" } ],
  "stop_processing": false,
  "summary": "When order value is more than $100.00: require a direct signature.",
  "created_at": "2026-10-13T09:20:16.502Z",
  "updated_at": "2026-10-13T09:20:16.502Z"
}
```

Now quote an order. The rules need to know what the order is worth, so send it as `order`:

```sh
curl -X POST $BASE/v1/rates \
  -H "authorization: Bearer $API_KEY" -H "content-type: application/json" \
  -d '{
    "to_address": { "name": "Jane Doe", "street1": "1 Market St", "city": "San Francisco", "state": "CA", "zip": "94105", "phone": "4155551234" },
    "parcel": { "weight": 16 },
    "order": { "value": "149.00" }
  }'
```

The quote is priced with the signature, and says what happened in `applied_rules`:

```json
{
  "object": "RateQuote",
  "rates": [ "…" ],
  "applied_rules": [
    {
      "id": "rule_01a1349006768453f51ebd736ab1",
      "name": "Signature on orders over $100",
      "effects": [
        { "action": "set_signature", "summary": "require a direct signature", "status": "applied" }
      ]
    }
  ],
  "excluded_rates": [],
  "recommended_rate": null
}
```

A $60 order sends no `applied_rules` entry for this rule: it didn't match, so it did nothing.
Shipments created with the same `order` store the signature in their `options` and keep the
`applied_rules` record, so you can always see why a label was bought the way it was.

---

## How rules work

### A rule is "when" and "then"

| Part | What it is |
| --- | --- |
| **Name** and **description** | For people. The name shows in the admin and in `applied_rules` |
| **When** | The conditions. A test such as "order value is more than $100", or several tests joined with *all of*, *any of* or *not* |
| **Then** | One or more actions: require a signature, remove a carrier, pick the cheapest rate, refuse the order |
| **Enabled** | Off means the rule is kept but ignored |
| **Stop processing** | When this rule matches, don't look at the rules below it. The admin calls it "Stop checking other rules" |

Every rule can be read back in plain English (`summary` in the API, the sentence on the card in the
admin): *"When order value is more than $100.00: require a direct signature."*

### Rules run top to bottom, and every match applies

The rules form one ordered list per mode. For each request VeloShip goes down the list and checks
each enabled rule. **Every rule that matches applies**, not just the first. When two matching rules
try to set the same thing (two different signatures, say), the rule **lower in the list wins**.
Put general rules at the top and exceptions below them.

### Conditions look at the request as you sent it

A rule's conditions are always checked against the request itself: the addresses, the parcel, the
`order`, the `reference`, the day. They never see what other rules decided. A rule that adds the tag
`fragile` cannot trigger another rule that tests for the tag `fragile`, and a rule below another one
can't rely on the first having changed the signature. This keeps results predictable: reordering
rules only changes who wins a conflict, never which rules match.

### Stop processing

Tick **Stop processing** on a rule and, when it matches, the rules below it aren't evaluated. Use
it for "VIP orders skip everything else" or an early `block`. Rules that were skipped this way show
as *not checked* in **Try it** and have `"evaluated": false` in a [test](#testing-rules). A rule
that doesn't match never stops anything.

### Test mode and live mode keep separate rules

Rules live with your [settings](https://ship.vprev.dev/docs/md/api.md#settings), one set per mode. Your test rules never touch
live shipments. Build and try rules in test mode, then **Copy to live** (admin, test mode) or
`POST /v1/rules/copy_to_live`. That **replaces** every live rule with the test rules; see
[Copying rules to live](#copying-rules-to-live).

### When rules run

| Request | Rules run? |
| --- | --- |
| `POST /v1/rates` | Yes |
| `POST /v1/shipments`, with or without `service` | Yes, once when the shipment is created |
| `POST /v1/batches` and `POST /v1/rates/compare` | Yes, for each order |
| `POST /v1/shipments/:id/rerate` | The limits and the pick run again; see [Rules and re-rating](#rules-and-re-rating) |
| `POST /v1/shipments/:id/buy` | No, but a service your rules removed can't be bought |
| Return labels | No |
| Anything with `apply_rules: false` | No |

A saved shipment keeps the decisions its rules made. Editing a rule later doesn't rewrite old
shipments.

### What rules cost you

- **No extra carrier calls.** A carrier your rules exclude isn't asked for a quote at all, which makes
  quotes faster and cheaper. Limits on services, price or delivery time are applied to the rates that
  come back.
- **No extra database reads.** Rules load together with your settings, which VeloShip already reads for
  every request. With no enabled rules, the check costs nothing.
- **One exception.** If an enabled rule tests `to.residential` and the address's home-or-business
  flag isn't known, VeloShip classifies it before evaluating, the same check a quote would run anyway.
  The answer is saved on the address.

---

## Who wins: precedence

Two kinds of things can disagree: what *you asked for in the request* and what *rules want*, and rules
among themselves. The order of authority:

1. **Limits always apply.** Rules that remove carriers, services or expensive or slow rates
   (`exclude_carriers`, `only_carriers`, `exclude_services`, `only_services`, `max_delivery_days`,
   `max_price`) and `block` are policies, not suggestions. They apply even if the request asked for
   the thing they remove.
2. **The request's own explicit choices win** over rules that *set* the same thing.
3. **Among rules, the later one wins** a conflict over a single setting.
4. **Some things accumulate** instead of competing: limits (all of them apply), tags and notes.

### The request's explicit choices win

If your integration sends a value, a rule that would set the same thing is **kept out** and says so
(`"status": "kept"`). Rules fill gaps; they don't overrule people and systems that were specific.

| What a rule sets | Wins when the request sent |
| --- | --- |
| `set_signature` | `options.signature` |
| `set_declared_value` | `options.declared_value` |
| `set_saturday_delivery` | `options.saturday_delivery` |
| `one_rate` | `options.one_rate`; on batches and comparisons also the batch's or order's `one_rate` |
| `add_protection` | `protection` |
| `use_preset` | A `parcel` that is a preset name, has a `preset`, `length`/`width`/`height`, or a `predefined_package` |
| `set_from_address` | `from_address` |
| `pick` | A specific `service` code (for example `FEDEX_GROUND`) |

Sending `options.signature: "none"` counts: it is an explicit choice, and a rule won't add a signature.

**`cheapest`, `fastest` and `best_value` are different.** When a batch order or a comparison says
`service: "cheapest"`, that is a default strategy, not a promise about one specific service, so a
`pick` rule **may replace it**. A specific service code such as `FEDEX_GROUND` is a decision, and a
rule's `pick` never replaces it. This is a deliberate design choice: it lets a shop say "for orders over $500 pick
the fastest rate" as a rule even when the batch was sent with a blanket `cheapest`. If you want a
batch to ignore the pick rules entirely, name the service or send `apply_rules: false`.

### Limits always apply

Suppose a rule excludes FedEx Ground for Hawaii. A caller who then sends `service: "FEDEX_GROUND"` for a
Hawaiian address doesn't get Ground: the rate was removed before the choice, so the request fails with
[`RULES.SERVICE_EXCLUDED`](#rulesservice_excluded), naming the rule. That is the point of a limit. To let
one request through, send `apply_rules: false`.

When the limits remove **every** rate, the request fails with [`RULES.NO_RATE`](#rulesno_rate), which lists what was removed
and by which rule, so an empty result is never a mystery. The one exception is a price cap you mark as soft:
[`max_price` with `if_none_left: "relax"`](#max_price) steps aside instead of removing the last rates.

### Later rules win single settings

These are "one value" settings: signature, declared value, protection, Saturday delivery, parcel
preset, One Rate, ship-from address, pick, and the preferred carrier. If several matching rules set one, the **last
matching rule's** action is used and the earlier ones are marked `"status": "overridden"`.

### Some things accumulate

- **Limits add up (AND).** If one rule says "only UPS or USPS" and another says "exclude USPS", what
  remains is UPS. Each limit is applied to every rate and a rate must pass all of them. Soft price caps
  (`if_none_left: "relax"`) come after the others and only apply when something would be left; see
  [`max_price`](#max_price).
- **Tags and notes** from every matching rule are collected (duplicates are dropped).
- `block`: the first matching `block` decides the message.

### What each effect's status means

`applied_rules` lists every rule that matched, and for each of its actions a `status`:

| Status | Meaning |
| --- | --- |
| `applied` | It took effect |
| `overridden` | A later matching rule set the same thing, so this one was replaced. The `reason` names the later rule |
| `kept` | The request's own value won (see the table above). The `reason` says which |
| `skipped` | It couldn't apply. The `reason` says why: protection is off in settings or the value is over the maximum; a percent action but no order value; a cap over shipping paid but no `order.shipping_paid`; the preset doesn't exist; the ship-from address was deleted; comparing One Rate somewhere that can't compare |
| `relaxed` | A `max_price` with `if_none_left: "relax"` would have removed every rate still left, so it removed none. The `reason` gives the cap |

Limiting actions also carry `rates_removed`, how many rates they took out.

Here is a real answer. A $600 order, with two signature rules (the second for $500 and up, listed
below the first), quoted through FedEx:

```json
"applied_rules": [
  {
    "id": "rule_01a1349006768453f51ebd736ab1",
    "name": "Signature on orders over $100",
    "effects": [
      { "action": "set_signature", "summary": "require a direct signature", "status": "overridden",
        "reason": "Replaced by “Adult signature on orders of $500 or more”" }
    ]
  },
  {
    "id": "rule_01a1349053baaaa5644a37842f83",
    "name": "Adult signature on orders of $500 or more",
    "effects": [
      { "action": "set_signature", "summary": "require an adult signature", "status": "applied" },
      { "action": "add_tag", "summary": "tag it “high-value”", "status": "applied" }
    ]
  }
]
```

And one where the caller got there first. The request already said `options.signature: "adult"`:

```json
{
  "action": "set_signature",
  "summary": "require a direct signature",
  "status": "kept",
  "reason": "The request set options.signature"
}
```

A rule that matched but found everything already decided still appears in the list, which is how
you see that it *would* have acted.

### A worked example, start to finish

Rules, top to bottom:

1. *Signature over $100* sets a direct signature when `order.value > 100`.
2. *High value* sets an adult signature and tags `high-value` when `order.value >= 500`.
3. *No Ground to Hawaii* excludes `FEDEX_GROUND` when `to.state` is `HI`.
4. *Rush orders* picks `fastest` when `order.shipping_method` contains `expedited`.

| Request | Result |
| --- | --- |
| $60 order to Ohio | No rule matches. Nothing changes |
| $149 order to Ohio | Rule 1: direct signature |
| $600 order to Ohio | Rules 1 and 2 match; rule 2 is later, so the signature is **adult** and rule 1 is `overridden`. Tag `high-value` |
| $600 order to Ohio with `options.signature: "direct"` | Both signature rules are `kept`; the request wins. The tag still gets added |
| $149 order to Hawaii | Rule 1 sets the signature; rule 3 removes Ground. `excluded_rates` lists it |
| Same order, caller sends `service: "FEDEX_GROUND"` | `RULES.SERVICE_EXCLUDED`: the limit wins over the request |
| Batch order `service: "cheapest"`, `shipping_method: "Expedited"` | Rule 4's `fastest` replaces the batch's `cheapest` |
| Batch order `service: "FEDEX_2_DAY"`, `shipping_method: "Expedited"` | `kept`: a specific service beats a pick |

### Rules and re-rating

Re-rating (`POST /v1/shipments/:id/rerate`, or the batch's own re-rate when rates expire) fetches new
prices for a shipment that hasn't been bought. Rules treat it like this:

- What rules changed *before quoting* (signature, declared value, protection, preset, ship-from address,
  One Rate) is already stored in the shipment's options and parcel, and stays as it was.
- The **limits and the pick** run again from your rules as they are *now*, against the stored addresses,
  parcel and `order`. So a rule you added since can remove a rate, and a rule you removed stops removing it.
  `ship.day_of_week` and `ship.date` use the time of the re-rate.
- Tags and notes found now are added to the ones already stored. A `block` rule that matches now makes
  the re-rate fail with `RULES.BLOCKED`.
- The stored shipment has its parcel expanded, so `parcel.preset` is empty on a re-rate, and `source` is `api`.
- A shipment created with `apply_rules: false` is never evaluated by rules, also when re-rated.

---

## Conditions: the "when"

### The shape

A condition is a small tree. A **test** is a leaf; **groups** combine tests.

```json
{ "field": "order.value", "op": "gt", "value": "100.00" }
```

| Key | Meaning |
| --- | --- |
| `field` | What to look at; see [Fields](#fields) |
| `op` | How to compare; see [Operators](#operators) |
| `value` | What to compare with. A single value, a list (`in`, `not_in`, `matches_any_zip`), a `[min, max]` pair (`between`), or nothing at all (`exists`, `not_exists`) |
| `unit` | Only for weight (`oz`, `lb`, `g`, `kg`) and size fields (`in`, `cm`) |

Groups:

| Group | True when |
| --- | --- |
| `{ "all": [ … ] }` | Every test inside is true ("and") |
| `{ "any": [ … ] }` | At least one test inside is true ("or") |
| `{ "not": { … } }` | The test inside is false |

Groups nest, up to **5 levels**, with at most **40 tests** in a rule. A rule's `when` is always one
group or test; the admin editor and the templates use `all` at the top.

```json
{
  "all": [
    { "field": "to.country", "op": "eq", "value": "US" },
    { "any": [
        { "field": "order.tags", "op": "contains", "value": "fragile" },
        { "field": "order.skus", "op": "in", "value": ["GLASS-01", "GLASS-02"] }
    ] },
    { "not": { "field": "to.po_box", "op": "eq", "value": true } }
  ]
}
```

"Destination is in the US, **and** the order is tagged fragile **or** contains one of two SKUs,
**and** the address is **not** a PO box."

**An empty `all` matches everything**: `{ "all": [] }`. Use it for rules that always apply, such as
"prefer UPS unless FedEx is $1 cheaper". An empty `any` matches nothing.

### Values are checked strictly, then normalized

The server rejects a rule it can't evaluate, rather than saving something that silently never
matches. A rule is validated when you create or update it, and every problem comes back at once
with the path to it:

```json
{
  "error": {
    "code": "PARAMETER.INVALID",
    "message": "Request validation failed",
    "details": [
      { "field": "when.all.0.value", "message": "Use a number that is zero or more" },
      { "field": "when.all.0.unit", "message": "Use one of: oz, lb, g, kg" },
      { "field": "when.all.1.op", "message": "\"between\" doesn't fit to.state (a state field). Use one of: eq, neq, in, not_in, exists, not_exists" },
      { "field": "when.all.2.field", "message": "Unknown field \"order.valu\". Use one of: to.country, to.state, to.zip, …" },
      { "field": "then.0", "message": "Give exactly one of: amount, percent_of_order_value" },
      { "field": "then.1.type", "message": "Invalid discriminator value. Expected 'set_signature' | 'set_declared_value' | … | 'block'" }
    ]
  }
}
```

`field` is the path into your JSON (`when.all.0.value` is the `value` of the first test inside the
top-level `all`). That body had a weight of `"heavy"` in `stone`, `between` on a state, a mistyped field, and two
conflicting amounts, plus an action type that doesn't exist.

Saved values are tidied: states and countries are upper-cased, money becomes `"100.00"`, text is
trimmed. Rules read back in that form (`"value": "ca"` is stored as `"CA"`).

### Rules never fail on missing information

Not every request has every fact: a quote might not send an `order`, a parcel in carrier packaging
has no dimensions, an address may not have been classified as a home. A test on a missing fact
doesn't error; it just can't be satisfied:

| Operator | When the fact is missing |
| --- | --- |
| `eq`, `gt`, `gte`, `lt`, `lte`, `between`, `in`, `contains`, `starts_with`, `matches_any_zip` | **false** |
| `neq`, `not_in`, `not_contains` | **true** (a missing value is "not equal") |
| `exists` | false |
| `not_exists` | true |

Mind the second row: `order.value neq "100.00"` matches an order that has no value at all. When you
mean "has a value and it is not 100", use `all` with `exists`. Rules that rely on the order value
("orders over $100", percent actions) simply do nothing for requests that don't send one. The rule list
adds an *info* note to a percent action whose rule doesn't test `order.value`, and to a cap over shipping paid whose
rule doesn't test `order.shipping_paid`.

### Text is compared ignoring case

`"Honolulu"`, `"honolulu"` and `"HONOLULU"` are the same. That goes for city, company, references,
shipping method, packaging, preset names, tags and SKUs. Carrier **service codes** in actions
(`FEDEX_GROUND`) are the one exception: they are matched exactly, as the carrier spells them.

---

### Fields

There are 29 fields, in five groups. The `unit` of weight and size fields is optional.

#### Destination

| Field | What it is | Operators | Value | Notes |
| --- | --- | --- | --- | --- |
| `to.country` | Destination country | `eq` `neq` `in` `not_in` `exists` `not_exists` | Two letters, e.g. `"US"` | Upper-cased before comparing, so `"us"` works too. |
| `to.state` | Destination state | `eq` `neq` `in` `not_in` `exists` `not_exists` | State or province code, e.g. `"HI"` | Compared ignoring case. Armed-forces codes `AA`, `AE`, `AP` and territories such as `PR`, `GU` are just states here. |
| `to.zip` | Destination ZIP | `eq` `neq` `in` `not_in` `starts_with` `between` `matches_any_zip` `exists` `not_exists` | A ZIP or postal code; a prefix for `starts_with`; two ZIPs for `between`; prefixes and ranges for `matches_any_zip` | A ZIP+4 is cut to its first five digits and spaces are removed, so `94105-1234` is `94105` and `K1A 0B1` is `K1A0B1`. See [ZIP codes](#zip-codes). |
| `to.city` | Destination city | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | Text, e.g. `"Honolulu"` | Ignores case and surrounding spaces. |
| `to.residential` | Destination type | `eq` `neq` `exists` `not_exists` | `true` (a home) or `false` (a business) | Only known when you send `residential` on the address, the saved address says so, or the carrier has classified it. See [Residential](#residential-homes-and-businesses). |
| `to.po_box` | PO box | `eq` `neq` | `true` or `false` | Worked out from `street1` and `street2`. See [PO boxes and military addresses](#po-boxes-and-military-addresses). |
| `to.military` | Military address | `eq` `neq` | `true` or `false` | State `AA`, `AE` or `AP`, or a city of exactly `APO`, `FPO` or `DPO`. |
| `to.company` | Destination company | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | Text, or no value with `exists` / `not_exists` | `exists` is the simplest way to tell a business from a person. |

#### Ship from

| Field | What it is | Operators | Value | Notes |
| --- | --- | --- | --- | --- |
| `from.state` | Ship-from state | `eq` `neq` `in` `not_in` `exists` `not_exists` | State code, e.g. `"TN"` | The from address you sent, else your default from address. |
| `from.zip` | Ship-from ZIP | `eq` `neq` `in` `not_in` `starts_with` `between` `matches_any_zip` `exists` `not_exists` | As `to.zip` | Same normalization as `to.zip`. |
| `from.address_id` | Ship-from address | `eq` `neq` `in` `not_in` `exists` `not_exists` | A saved address ID, `"adr_…"` | Set when the origin is a saved address, such as your default from address or one you passed as `{ "id": "adr_…" }`. Empty for an address sent inline on a quote. |

#### Package

| Field | What it is | Operators | Value | Notes |
| --- | --- | --- | --- | --- |
| `parcel.weight` | Package weight | `eq` `neq` `gt` `gte` `lt` `lte` `between` | A number, with `unit` `oz` (default), `lb`, `g` or `kg` | Always known. Compared in ounces, rounded to four decimals. |
| `parcel.length` | Package length | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A number, with `unit` `in` (default) or `cm` | Missing when the parcel has no dimensions, such as carrier packaging. |
| `parcel.width` | Package width | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | As `parcel.length` | Missing without dimensions. |
| `parcel.height` | Package height | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | As `parcel.length` | Missing without dimensions. |
| `parcel.girth` | Package girth | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | As `parcel.length` | `2 × (the two shorter sides added together)`. Needs all three dimensions. |
| `parcel.longest_side` | Package's longest side | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | As `parcel.length` | The largest of length, width and height. Needs all three dimensions. |
| `parcel.preset` | Parcel preset | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | A preset name, e.g. `"small-box"` | Ignores case. Missing when the parcel was sent in full. Not available when a shipment is re-rated. |
| `parcel.packaging` | Packaging | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | A packaging code, e.g. `"FEDEX_"` for `starts_with` | `YOUR_PACKAGING` when the parcel isn't carrier packaging. Ignores case. |

#### Order

| Field | What it is | Operators | Value | Notes |
| --- | --- | --- | --- | --- |
| `order.value` | Order value | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A dollar amount as a string, e.g. `"100.00"` | `order.value`, or the sum of the items' `value × quantity` when every item has a value. Missing otherwise. |
| `order.currency` | Order currency | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | A three-letter code, e.g. `"USD"` | Compared as text. Nothing is converted. |
| `order.tags` | Order tags | `contains` `not_contains` `in` `not_in` `exists` `not_exists` | A tag; a list for `in` / `not_in` | Ignores case. `contains` asks whether the order has that tag; `in` whether it has any of the listed tags. |
| `order.shipping_method` | Checkout shipping method | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | Text, e.g. `"expedited"` | What the customer picked at checkout. `contains` ignores case. |
| `order.shipping_paid` | Shipping the customer paid | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A dollar amount as a string, e.g. `"12.95"` | What the customer paid for shipping at checkout, in `order.currency`. Missing when you don't send it; `"0.00"` is free shipping. [`max_price`](#max_price) can cap the label at it. |
| `order.item_count` | Number of items | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A whole number | The sum of the items' quantities. Missing when the order has no `items`. |
| `order.skus` | SKUs | `contains` `not_contains` `in` `not_in` `exists` `not_exists` | A SKU; a list for `in` / `not_in` | Ignores case. Works like `order.tags`. |

#### Request

| Field | What it is | Operators | Value | Notes |
| --- | --- | --- | --- | --- |
| `reference` | Reference | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | Text, e.g. `"WHOLESALE-"` for `starts_with` | The `reference` of the request. Missing when you send none. |
| `source` | Request source | `eq` `neq` `in` `not_in` | `api`, `admin` or `batch` | `api` for a key, `admin` for the admin page, `batch` for orders in a batch or a comparison. |
| `ship.day_of_week` | Day of the week | `eq` `neq` `in` `not_in` | `mon`, `tue`, `wed`, `thu`, `fri`, `sat` or `sun` | The weekday when the request is made, in **UTC**. |
| `ship.date` | Ship date | `eq` `neq` `gt` `gte` `lt` `lte` `between` | A date, `"YYYY-MM-DD"` | Today's date in **UTC**. Dates compare as text, so the format must be exact. |

#### Field types and the values they take

| Field type | Fields | Operators | Value |
| --- | --- | --- | --- |
| text | `to.city`, `to.company`, `parcel.preset`, `parcel.packaging`, `order.currency`, `order.shipping_method`, `reference` | `eq` `neq` `in` `not_in` `contains` `not_contains` `starts_with` `exists` `not_exists` | A string, 1 to 200 characters. Compared ignoring case. |
| country | `to.country` | `eq` `neq` `in` `not_in` `exists` `not_exists` | A two-letter code. |
| state | `to.state`, `from.state` | `eq` `neq` `in` `not_in` `exists` `not_exists` | A state code, up to 40 letters, digits, spaces, `.` `'` `-`. |
| zip | `to.zip`, `from.zip` | `eq` `neq` `in` `not_in` `starts_with` `between` `matches_any_zip` `exists` `not_exists` | See [ZIP codes](#zip-codes). |
| boolean | `to.residential`, `to.po_box`, `to.military` | `eq` `neq` | `true` or `false`. |
| id | `from.address_id` | `eq` `neq` `in` `not_in` `exists` `not_exists` | A saved address ID, `adr_` followed by letters and digits. |
| weight | `parcel.weight` | `eq` `neq` `gt` `gte` `lt` `lte` `between` | A number from 0 to 10,000,000, with an optional `unit`. |
| length | `parcel.length`, `parcel.width`, `parcel.height`, `parcel.girth`, `parcel.longest_side` | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A number from 0 to 10,000,000, with an optional `unit`. |
| money | `order.value`, `order.shipping_paid` | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A dollar amount as a string with up to two decimals (`"100.00"`), or a number. 0 to 9,999,999. |
| count | `order.item_count` | `eq` `neq` `gt` `gte` `lt` `lte` `between` `exists` `not_exists` | A whole number from 0 to 1,000,000. |
| tags | `order.tags`, `order.skus` | `contains` `not_contains` `in` `not_in` `exists` `not_exists` | A string, 1 to 200 characters. Compared ignoring case. |
| enum | `source`, `ship.day_of_week` | `eq` `neq` `in` `not_in` | One of the field's listed values (not case sensitive). |
| date | `ship.date` | `eq` `neq` `gt` `gte` `lt` `lte` `between` | A date as `YYYY-MM-DD`. |

### Operators

There are 15 operators. Which ones a field offers depends on its type (see the table above), and the
admin only shows the ones that fit.

| Operator | Reads as | Value | Meaning |
| --- | --- | --- | --- |
| `eq` | is | one value | Equal to the value. Text is compared ignoring case. |
| `neq` | is not | one value | Anything but the value. A missing value counts as not equal. |
| `in` | is any of | a list | Equal to at least one value in the list. On tags and SKUs: at least one of them is in the list. |
| `not_in` | is none of | a list | Equal to none of the values in the list. On tags and SKUs: none of them is in the list. |
| `gt` | is more than | one value | Greater than the value. |
| `gte` | is at least | one value | Greater than or equal to the value. |
| `lt` | is less than | one value | Less than the value. |
| `lte` | is at most | one value | Less than or equal to the value. |
| `between` | is between | `[min, max]` | From the first value to the second, both included. For ZIP codes, a range of ZIP codes. |
| `contains` | contains | one value | Text: contains the value anywhere. Tags and SKUs: one of them is the value. |
| `not_contains` | doesn't contain | one value | The opposite of contains. |
| `starts_with` | starts with | one value | Begins with the value, e.g. a ZIP prefix such as 967. |
| `exists` | is filled in | none | Has a value. Takes no value itself. |
| `not_exists` | is empty | none | Has no value. Takes no value itself. |
| `matches_any_zip` | is in any of | a list | Matches any ZIP prefix ("967") or range ("96701-96899") in the list. |

On **tags and SKUs** the operators read as sets: `contains` means "has this tag", `in` means "has any of
these tags", `not_in` means "has none of these", `exists` means "has at least one".

### Details that matter

#### Units

| Kind | Units | Default | Compared as |
| --- | --- | --- | --- |
| Weight | `oz`, `lb`, `g`, `kg` | `oz` | Ounces. 1 lb = 16 oz, 1 kg ≈ 35.274 oz, 1 g ≈ 0.0353 oz |
| Size | `in`, `cm` | `in` | Inches. 1 cm ≈ 0.3937 in |

`{ "field": "parcel.weight", "op": "gt", "value": 20, "unit": "lb" }` means over 20 pounds. Values are
rounded to four decimals before comparing, so `16 oz` equals `1 lb`. A `unit` on a field that has
none (`order.value`) is rejected. In test results and traces, the `actual` weight is shown in ounces, sizes in inches
and money in dollars.

#### ZIP codes

The destination and ship-from ZIP are normalized first: spaces are removed, letters are upper-cased, and
a US ZIP+4 (`94105-1234`, `941051234`) becomes its five digits. Then:

| You want | Use | Matches |
| --- | --- | --- |
| One ZIP | `eq` with `"94105"` | Exactly that ZIP |
| A list | `in` with `["94105", "94107"]` | Any of them |
| A prefix | `starts_with` with `"967"` | `96701`, `96799`, … Works for letters too: `"K1A"` |
| A range | `between` with `["96701", "96899"]` | Any ZIP numerically between them. Both must be digits and the same length |
| Prefixes and ranges mixed | `matches_any_zip` with `["967", "968", "995-999", "96701-96899"]` | Any entry matches. `"995-999"` is a range of three-digit prefixes: `99501` is in it |

A range compares the first digits of the ZIP, as many as the range's ends have, so `"967-968"` covers
all of `967xx` and `968xx`. Ranges only work for digit ZIPs; a Canadian postal code never falls in one,
but `starts_with` and `matches_any_zip` prefixes work for it.

#### Residential: homes and businesses

`to.residential` is `true` for a home and `false` for a business. It is **only known** when:

- you sent `residential` on the destination address, or
- the saved address already has it (it was entered or verified), or
- the carrier has classified the address.

When an enabled rule uses `to.residential` and the answer isn't known yet, VeloShip checks with the carrier
before evaluating the rules and saves the answer on the address. If the check fails, the flag stays
unknown. An unknown flag matches **neither side**: `eq true` and `eq false` are both false. (`neq true`
is true for an unknown address, per the missing-fact rule above.) Dry runs without `quote: true`
don't run the carrier check; see [Testing rules](#testing-rules).

#### PO boxes and military addresses

`to.po_box` is true when `street1` or `street2` contains `P.O. Box`, `PO Box`, `P O Box`, `POBox`,
`POB` or `Post Office Box` (any capitalization, with or without periods). A street like `Box 12 Main St`
doesn't count. `to.military` is true when the state is `AA`, `AE` or `AP`, or the city is exactly `APO`, `FPO` or `DPO`.

#### Girth and longest side

`parcel.longest_side` is the largest of length, width and height. `parcel.girth` is **twice the sum of the two
shorter sides**; a 30 × 12 × 8 in box has a girth of 2 × (12 + 8) = 40 in. Both fields need all three dimensions; a parcel
sent by weight alone, or in carrier packaging without sizes, has neither.

#### Time is UTC

`ship.day_of_week` and `ship.date` use the moment the request is made, **in UTC**, not your warehouse's
time zone. A request at 8 p.m. Tuesday in California is already Wednesday. Plan cutoffs and holiday windows
with that in mind.

#### Where the facts come from

| Field | Taken from |
| --- | --- |
| `to.*` | The `to_address` of the request, or the saved address it points to |
| `from.*` | The `from_address` of the request, else your default from address |
| `parcel.*` | The parcel after presets are expanded |
| `order.*` | The request's `order`; see [Order details](#order-details) |
| `reference` | The request's `reference` |
| `source` | Who sent it: `api` (an API key), `admin` (signed in to the admin; quotes and shipments), `batch` (orders in a batch or comparison, wherever it came from) |

---

## Actions: the "then"

A rule has 1 to 20 actions. There are 18 kinds, in four groups by what they do and when:

| Group | When it runs | Actions |
| --- | --- | --- |
| **Change the shipment** | Before quoting: it edits the request, so the quote is priced with the change | `set_signature`, `set_declared_value`, `add_protection`, `set_saturday_delivery`, `use_preset`, `one_rate`, `set_from_address` |
| **Limit the choices** | While quoting: carriers are skipped, then rates removed | `exclude_carriers`, `only_carriers`, `exclude_services`, `only_services`, `max_delivery_days`, `max_price` |
| **Pick the rate** | After quoting: chooses or recommends a rate | `pick`, `prefer_carrier` |
| **Flag it** | Recorded on the shipment | `add_tag`, `add_note`, `block` |

| Action | Group | In the admin | Example |
| --- | --- | --- | --- |
| `set_signature` | Change the shipment | Require a signature | `{"type":"set_signature","signature":"adult"}` |
| `set_declared_value` | Change the shipment | Declare a value | `{"type":"set_declared_value","percent_of_order_value":100}` |
| `add_protection` | Change the shipment | Add package protection | `{"type":"add_protection","percent_of_order_value":100}` |
| `set_saturday_delivery` | Change the shipment | Saturday delivery | `{"type":"set_saturday_delivery","enabled":true}` |
| `use_preset` | Change the shipment | Use a parcel preset | `{"type":"use_preset","preset":"small-box"}` |
| `one_rate` | Change the shipment | FedEx One Rate | `{"type":"one_rate","mode":"force"}` |
| `set_from_address` | Change the shipment | Ship from another address | `{"type":"set_from_address","address_id":"adr_01a131ca1cc83766d0ac435ef"}` |
| `exclude_carriers` | Limit the choices | Exclude carriers | `{"type":"exclude_carriers","carriers":["ups"]}` |
| `only_carriers` | Limit the choices | Only use carriers | `{"type":"only_carriers","carriers":["usps"]}` |
| `exclude_services` | Limit the choices | Exclude services | `{"type":"exclude_services","services":["FEDEX_GROUND"]}` |
| `only_services` | Limit the choices | Only allow services | `{"type":"only_services","services":["FEDEX_2_DAY","STANDARD_OVERNIGHT"]}` |
| `max_delivery_days` | Limit the choices | Latest delivery | `{"type":"max_delivery_days","days":3}` |
| `max_price` | Limit the choices | Price cap | `{"type":"max_price","percent_of_order_value":15}` |
| `pick` | Pick the rate | Pick a rate | `{"type":"pick","service":"cheapest"}` |
| `prefer_carrier` | Pick the rate | Prefer a carrier | `{"type":"prefer_carrier","carrier":"ups","within":"1.00"}` |
| `add_tag` | Flag it | Tag the shipment | `{"type":"add_tag","tag":"fragile"}` |
| `add_note` | Flag it | Add a note | `{"type":"add_note","note":"Gift: don't include the invoice"}` |
| `block` | Flag it | Refuse to ship | `{"type":"block","message":"We don't ship to PO boxes"}` |

A rule can mix any of them. The examples below are single actions, ready to put in a `then` list.

### Change the shipment

These run **before** quotes are requested, so a signature or declared value is part of the price you see.
A request's own value wins (see [precedence](#the-requests-explicit-choices-win)); later rules win
over earlier ones.

#### `set_signature`

```json
{ "type": "set_signature", "signature": "adult" }
```

`signature` is `none`, `indirect`, `direct` or `adult`: the same values as `options.signature`. `none` is
useful to cancel an earlier rule's signature for wholesale orders.

#### `set_declared_value`

```json
{ "type": "set_declared_value", "percent_of_order_value": 100 }
{ "type": "set_declared_value", "value": "250.00" }
```

Sets `options.declared_value`. Give **exactly one** of `value` (dollars, above 0) and `percent_of_order_value`
(above 0, up to 100). The percent form needs an order value; without one the action is `skipped` ("The request has
no order value"), as it is when the result would be $0.

#### `add_protection`

```json
{ "type": "add_protection", "percent_of_order_value": 100 }
{ "type": "add_protection", "value": "300.00" }
```

Adds [package protection](https://ship.vprev.dev/docs/md/api.md#package-protection) for a fixed value or a percent of the order value, with the fee
priced from your protection settings. It is `skipped`, with the reason, when:

- protection is turned off in settings ("Package protection is turned off; enable it in settings first"),
- the value is over your protection maximum,
- a percent action has no order value, or the value works out to $0.

A skipped protection never fails the request. A request that already has `protection` keeps its own.

#### `set_saturday_delivery`

```json
{ "type": "set_saturday_delivery", "enabled": true }
```

Sets `options.saturday_delivery`. `enabled` defaults to `true`; `false` turns Saturday delivery off.

#### `use_preset`

```json
{ "type": "use_preset", "preset": "small-box" }
```

Packs the shipment in one of your [parcel presets](https://ship.vprev.dev/docs/md/api.md#using-presets), keeping the weight the caller sent.
Preset names are lowercase letters, digits, `-` and `_`. The action is `skipped` when the request
already chose its own packaging (a preset, dimensions or carrier packaging) or when the preset doesn't exist (the
rule list warns about that). Because the preset is applied before quoting, rules later in the list
that test `parcel.length` and friends **still see the request as sent**, not the preset's size.

#### `one_rate`

```json
{ "type": "one_rate", "mode": "force" }
```

| `mode` | Effect |
| --- | --- |
| `force` | Price the shipment as FedEx One Rate. Needs FedEx packaging (`parcel.predefined_package`), so test `parcel.packaging starts_with "FEDEX_"` in the same rule. A parcel that isn't eligible is reported per carrier in `carrier_errors` (`SHIPMENT.ONE_RATE.INELIGIBLE`), and when no carrier can quote it you get `CARRIER.ALL_FAILED` |
| `off` | Don't use One Rate, as if `options.one_rate` were absent |
| `compare` | In **batches** and `POST /v1/rates/compare`: quote each order in its own box and in the smallest One Rate box it fits, and take the cheaper (or faster, with `fastest`). Everywhere else this is `skipped`, because a single quote has nothing to compare |

The request's `options.one_rate`, and a batch's or order's `one_rate`, win. Without either, a batch uses
`as_given` (the parcel as sent) unless a rule says `compare`, and `/v1/rates/compare` uses `compare`
unless a rule says otherwise. See [choosing One Rate automatically](https://ship.vprev.dev/docs/md/api.md#choosing-one-rate-automatically).

#### `set_from_address`

```json
{ "type": "set_from_address", "address_id": "adr_01a131ca1cc83766d0ac435ef" }
```

Ships from another saved address, such as a second warehouse. It replaces your **default** from address, never a
`from_address` the caller sent. The address must exist in the same mode (test and live addresses are different
records, so copying rules to live needs the live warehouse's own ID); if it no longer exists the action is `skipped`
("That address no longer exists"). The value `adr_…` of your own warehouses is in the admin under **Settings**, or
from `POST /v1/addresses`.

### Limit the choices

Limits are policies: they always apply, accumulate across rules, and aren't overridden by the request. See
[limits always apply](#limits-always-apply).

#### `exclude_carriers` and `only_carriers`

```json
{ "type": "exclude_carriers", "carriers": ["ups"] }
{ "type": "only_carriers", "carriers": ["usps"] }
```

1 to 10 carrier IDs or names, case-insensitive: `fedex`, `usps`, `ups` and `mockex` (the test carrier). They are
stored as lowercase IDs; an unknown name is rejected (`GET /v1/carriers` lists them). A carrier the rules rule out
**is not called at all**: no carrier API call is made, so the quote is faster. It appears in `excluded_rates` with
`service: null` and `rate: null`. `only_carriers` removes every account of every other carrier.

If you name a carrier you have no account for, the rule list warns you, because
`only_carriers: ["usps"]` without a USPS account leaves nothing to buy.

#### `exclude_services` and `only_services`

```json
{ "type": "exclude_services", "services": ["FEDEX_GROUND"] }
{ "type": "only_services", "services": ["FEDEX_2_DAY", "STANDARD_OVERNIGHT"] }
```

1 to 30 service codes, exactly as `GET /v1/carriers` and rates show them (`FEDEX_GROUND`, `USPS_GROUND_ADVANTAGE`;
the codes are case sensitive). Interchangeable services count as one: `FEDEX_GROUND` also covers its Home Delivery twin
`GROUND_HOME_DELIVERY`, so excluding one removes both. `only_services` keeps just those and removes every other rate,
from every carrier. A rate that is removed shows in `excluded_rates` with the rule and a reason such as
"FedEx Ground is excluded".

#### `max_delivery_days`

```json
{ "type": "max_delivery_days", "days": 3 }
```

1 to 30. Removes rates that arrive later than that many days, **and rates with no delivery estimate** (they can't be
shown to meet the limit). Arrival is the carrier's delivery date when it gives one, otherwise its day count.
Not to be confused with the batch field `max_delivery_days` (1 to 10), which limits what a batch buys but isn't a rule.

#### `max_price`

```json
{ "type": "max_price", "amount": "20.00" }
{ "type": "max_price", "percent_of_order_value": 15 }
{ "type": "max_price", "over_shipping_paid": "3.00", "if_none_left": "relax" }
```

Give **exactly one** cap:

| Cap | The rate may cost at most | Without the fact it needs |
| --- | --- | --- |
| `amount` | That many dollars (above 0) | |
| `percent_of_order_value` | That percent of the order's value (above 0, up to 1000; 15 means 15% of what the customer paid for the goods) | `skipped`: "The request has no order value" |
| `over_shipping_paid` | What the customer paid for shipping (`order.shipping_paid`) plus this many dollars. `"0.00"` means no more than they paid | `skipped`: "The request has no order.shipping_paid" |

A skipped cap removes nothing. A rate exactly at the cap stays.

**`if_none_left`** says what happens when the cap would remove every rate that is left:

- `"remove"` (the default) removes them anyway, so the request fails with [`RULES.NO_RATE`](#rulesno_rate). A low
  percent cap on a small order can do that.
- `"relax"` makes the cap soft: if it would leave no rate, it removes **none**, its effect is marked `"status": "relaxed"`
  with the cap in the `reason`, and nothing it would have removed appears in `excluded_rates`.

Soft caps are judged after every other limit, against the rates those left, one at a time in rule order (top to bottom, then
action order within a rule). Each one narrows what the next sees, unless it would empty it. Because every price cap is an upper
bound on the same price, the order of soft caps never changes the result, so you can stack them into a **fallback chain**:

```json
"then": [
  { "type": "max_price", "over_shipping_paid": "0.00", "if_none_left": "relax" },
  { "type": "max_price", "over_shipping_paid": "3.00", "if_none_left": "relax" },
  { "type": "pick", "service": "cheapest" }
]
```

With $8.00 paid for shipping:

| Rates quoted | First cap ($8.00) | Second cap ($11.00) | Left for the pick |
| --- | --- | --- | --- |
| $6.50, $9.75, $14.00 | applied: removes $9.75 and $14.00 | applied: removes nothing more | $6.50 |
| $9.75, $14.00 | relaxed | applied: removes $14.00 | $9.75 |
| $14.00, $18.00 | relaxed | relaxed | both; `cheapest` takes $14.00 |

A hard limit in the same request still applies first: with "overnight only" and the chain above, the chain chooses among
the overnight rates. In batches and `POST /v1/rates/compare` with One Rate comparison on, the rates in your own box and in the
One Rate box are filtered separately, so a soft cap is judged on each set by itself.

### Pick the rate

`pick` and `prefer_carrier` choose among the rates that survived the limits. They are explained in
[Picking a rate](#picking-a-rate).

```json
{ "type": "pick", "service": "cheapest" }
{ "type": "pick", "service": "FEDEX_2_DAY" }
{ "type": "prefer_carrier", "carrier": "ups", "within": "1.00" }
```

`pick.service` is `cheapest`, `fastest`, `best_value` or a service code. `prefer_carrier.within` is dollars
(above 0, optional).

### Flag it

```json
{ "type": "add_tag", "tag": "fragile" }
{ "type": "add_note", "note": "Gift: don't include the invoice" }
{ "type": "block", "message": "We don't ship to PO boxes" }
```

- **`add_tag`** (up to 64 characters) adds to the shipment's `tags`. Tags are a quick way to label shipments for
  filtering in your own system. They are separate from the order's tags, which are what rules *read*.
- **`add_note`** (up to 500 characters) adds to the shipment's `notes`, for whoever packs or reviews it.
- **`block`** (message up to 300 characters) stops the request with `422 RULES.BLOCKED` and your message. Nothing is
  quoted, bought or saved. The message is shown to whoever called, so write it for a person. With several matching `block` rules, the first one decides the message. Other effects still appear in
  `applied_rules` of the error.

Tags and notes from all matching rules are collected, without duplicates, in the order the rules ran.

---

## Picking a rate

Quoting returns many rates. **Picking** decides which one should be bought. Rules can pick in two ways:
`pick`, which names the rate (or the kind of rate) you want, and `prefer_carrier`, which tilts the choice towards a carrier.

### `pick` keywords

| `service` | Chooses |
| --- | --- |
| `cheapest` | The lowest price. Ties go to the earlier arrival |
| `fastest` | The earliest arrival (the delivery date when there is one, otherwise the day count; a rate with no estimate counts as slowest). Ties go to the lower price |
| `best_value` | The **fastest rate that costs no more than 20% (or $1, whichever is more) above the cheapest**. For a cheapest rate of $10, that allows up to $12; for $3, up to $4. Ties go to the lower price |
| A service code, e.g. `FEDEX_2_DAY` | That service's rate. If several carriers offer it, the cheapest of them |

`best_value` here is a **choice** of one rate. It is not the same as the `sort=best_value` of `POST /v1/rates`, which
orders the whole list.

### `prefer_carrier`

```json
{ "type": "prefer_carrier", "carrier": "ups", "within": "1.00" }
```

After the pick finds its winner, the preferred carrier's own best rate (by the same ordering) is compared with it:

- If the preferred carrier's rate costs **no more than the winner**, it wins (a tie, or cheaper).
- Otherwise it wins when it costs **less than `within` dollars more** than the winner.
- Otherwise the winner stands.

So `prefer UPS within $1.00` reads: *"UPS wins unless another carrier is $1.00 or more cheaper."* Without `within`,
only ties go to the preferred carrier. With `fastest`, a preference never costs arrival days: the preferred rate must also be no slower.

Worked example: UPS Ground $11.10, FedEx Ground $10.35, `prefer UPS within $1.00`. The gap is $0.75, below
$1.00, so UPS is picked. If FedEx were $10.05 (gap $1.05), FedEx would stand.

`prefer_carrier` also works with no `pick`: the recommendation is then the cheapest rate with the preference applied.

### Where a pick applies

| Where | What the pick does |
| --- | --- |
| `POST /v1/rates` | Sets `recommended_rate` (`carrier`, `carrier_account_id`, `service`). Nothing is bought |
| `POST /v1/shipments` | Sets `recommended_rate` on the shipment, with the rate's `id`. **It does not buy**: without a `service` the shipment waits for you to `buy` |
| `POST /v1/batches` | **Decides the rate each order buys**, replacing a `cheapest`, `fastest` or `best_value` strategy |
| `POST /v1/rates/compare` | Decides what each order would buy, like a batch |
| `POST /v1/shipments/:id/rerate` | Sets `recommended_rate` again from the current rules |

A request that names a specific service (`service: "FEDEX_GROUND"`) is not replaced; the effect is `kept`. If the
picked service isn't among the rates left, a quote has no `recommended_rate`, and a batch order fails with
`SHIPMENT.RATE.NOT_FOUND` (or `RULES.SERVICE_EXCLUDED` if a rule removed it).

Batches also accept `service: "best_value"` themselves, with the same meaning.

---

## Order details

Rules can only see what you tell them. The **`order`** object is how you describe the sale behind a
shipment. Nothing in it is sent to the carrier; it is for rules, and it is stored on the shipment.

```json
"order": {
  "value": "149.00",
  "currency": "USD",
  "tags": ["fragile", "gift"],
  "shipping_method": "Expedited",
  "shipping_paid": "12.95",
  "items": [
    { "sku": "MUG-01", "quantity": 2, "value": "20.00" },
    { "sku": "GLASS-07", "quantity": 1, "value": "109.00" }
  ]
}
```

| Field | Notes |
| --- | --- |
| `value` | What the order is worth, as a dollar string (a number works too). If you leave it out and every item has a `value`, it is the sum of `value × quantity` |
| `currency` | Three letters, upper-cased. Informational: **nothing is converted**, so rules compare `value` as given |
| `tags` | Up to 50, each up to 64 characters. Rules compare them ignoring case; the order keeps what you sent |
| `shipping_method` | What the customer chose at checkout, up to 100 characters |
| `shipping_paid` | What the customer paid for shipping at checkout, as a dollar string (a number works too), in `currency`. `"0.00"` for free shipping. Leave it out when you don't know it: `max_price` with `over_shipping_paid` is then skipped rather than capping at $0 |
| `items[]` | Up to 500. `sku` (up to 100), `quantity` (whole number, 1 to 100,000, default 1), `value` (price of one unit) |

The fields rules read are `order.value`, `order.currency`, `order.tags`, `order.shipping_method`, `order.shipping_paid`, `order.item_count`
(the sum of quantities) and `order.skus`. `order` is accepted on `POST /v1/rates`, `POST /v1/shipments`, each order of
`POST /v1/batches`, and each order of `POST /v1/rates/compare`. The shipment returns it as `order` with money as strings, in full:

```json
"order": {
  "value": "40.00",
  "currency": null,
  "tags": ["Gift"],
  "shipping_method": null,
  "shipping_paid": null,
  "items": [ { "sku": "MUG-01", "quantity": 2, "value": "20.00" } ]
}
```

A request without `order` is fine; rules that need order facts just don't match (see
[missing facts](#rules-never-fail-on-missing-information)).

### How Shopify will fill these in

> The Shopify integration is **not built yet**. This is the planned mapping, so you can design your rules
> against the fields now. Until it exists, send `order` yourself.

| Shopify | VeloShip |
| --- | --- |
| `total_price` or `subtotal_price` (the integration will use one of them) | `order.value` |
| `currency` | `order.currency` |
| `tags` (a comma-separated string) | `order.tags` (split on commas, trimmed) |
| `shipping_lines[0].title` (or `.code`) | `order.shipping_method` |
| `total_shipping_price_set.shop_money.amount` | `order.shipping_paid` |
| `line_items[].sku` | `order.items[].sku` |
| `line_items[].quantity` | `order.items[].quantity` |
| `line_items[].price` | `order.items[].value` |
| `name` (for example `#1001`) | `reference` |
| `shipping_address` | `to_address` |

If you want rules to ignore tax and shipping, base them on a subtotal; if you want them to work from what the customer
paid, use the total. The mapping is the one thing to decide before relying on dollar thresholds.

---

## Using rules from the API

### Endpoints

All under `/v1`. Rules belong to the mode of the key (`sk_test_` or `sk_live_`).

| Method and path | Scope | What it does |
| --- | --- | --- |
| `GET /rules` | `rules:read` | List rules in evaluation order, with `warnings` and `limit`. `?include=stats` adds how often each matched the last 200 shipments |
| `POST /rules` | `rules:write` | Create a rule. `position` (1-based) inserts it; the default is last. `201` |
| `GET /rules/:id` | `rules:read` | One rule |
| `PATCH /rules/:id` | `rules:write` | Change any of `name`, `description`, `enabled`, `when`, `then`, `stop_processing`, `position`. `when` and `then` are replaced whole. An empty body is `422` |
| `DELETE /rules/:id` | `rules:write` | Delete. `204` |
| `POST /rules/reorder` | `rules:write` | `{ "rule_ids": [ … ] }`, every rule exactly once, in the new order. Returns the list |
| `POST /rules/test` | `rules:read` | A dry run. See [Testing rules](#testing-rules) |
| `POST /rules/draft` | `rules:write` | Describe a rule in English; returns unsaved drafts. See [Describe it in English](#describe-it-in-english) |
| `POST /rules/copy_to_live` | `rules:write`, and an owner or admin session (or the root key) | Replace the live rules with the test rules. Test mode only |

The rule object:

| Field | Notes |
| --- | --- |
| `id` | `rule_…` |
| `object` | `"Rule"` |
| `name` | 1 to 100 characters |
| `description` | Up to 500 characters; may be empty |
| `enabled` | Defaults to `true` |
| `position` | 1 is evaluated first |
| `when` | The condition ([Conditions](#conditions-the-when)) |
| `then` | 1 to 20 actions |
| `stop_processing` | Defaults to `false` |
| `summary` | The rule in plain English, generated for you |
| `stats` | With `?include=stats`: `{ matched, window, last_matched_at }`. `matched` counts shipments, among the last `window` (up to 200), where the rule matched |
| `created_at`, `updated_at` | |

`GET /v1/rules` returns:

```json
{
  "object": "RuleList",
  "rules": [ { "id": "rule_01a1…", "object": "Rule", "…": "…" } ],
  "warnings": [
    {
      "severity": "warning",
      "code": "overlap",
      "rule_ids": ["rule_01a1349006768453f51ebd736ab1", "rule_01a1349053baaaa5644a37842f83"],
      "message": "“Signature on orders over $100” and “Adult signature on orders of $500 or more” both set the signature. When both match, the lower one wins."
    }
  ],
  "limit": 50
}
```

#### Warnings

Warnings point at problems that no single rule shows. They never block saving. The admin shows them above the list and on the
affected rules; `POST /v1/rules/test` returns them for the rules it tried.

| Code | Severity | Means |
| --- | --- | --- |
| `overlap` | warning | Two enabled rules set the same single setting (signature, declared value, protection, Saturday delivery, preset, One Rate, ship-from address, pick, preferred carrier), and they can't be shown to exclude each other. When both match, the lower one wins. Rules whose plain equality tests on the same field can't both be true (for example two different states) aren't flagged |
| `carrier_not_configured` | warning | `only_carriers` or `prefer_carrier` names a carrier with no enabled account |
| `service_not_offered` | warning | `only_services` or a `pick` names a service none of your carrier accounts offers |
| `preset_missing` | warning | `use_preset` names a preset that doesn't exist |
| `address_missing` | warning | `set_from_address` names an address that isn't saved in this mode |
| `contradiction` | warning | One rule both allows and excludes the same carrier |
| `unreachable` | warning | A rule that matches everything and stops makes the rules below it dead |
| `needs_order_value` | info | A percent action in a rule that doesn't test `order.value`: it does nothing for requests without an order value |
| `needs_shipping_paid` | info | A `max_price` with `over_shipping_paid` in a rule that doesn't test `order.shipping_paid`: it does nothing for requests that don't send it |

### Reading what rules did

`applied_rules` and the related fields are on every response that evaluates rules.

| Where | Fields added |
| --- | --- |
| Rate quote (`POST /v1/rates`) | `applied_rules`, `excluded_rates`, `recommended_rate` |
| Shipment object | `applied_rules`, `excluded_rates`, `recommended_rate` (with the rate's `id`), `tags`, `notes`, `order` |
| Batch items | `applied_rules` |
| Comparison items (`POST /v1/rates/compare`) | `applied_rules`, `excluded_rates` |
| Errors | `RULES.BLOCKED` and `RULES.NO_RATE` carry `applied_rules` next to `details` |

`excluded_rates` explains what rules took out, so a missing rate is never a puzzle:

```json
"excluded_rates": [
  {
    "carrier": "FedEx",
    "carrier_account_id": "ca_env_fedex",
    "service": "FEDEX_GROUND",
    "service_name": "FedEx Ground",
    "rate": "10.35",
    "removed_by": [
      { "rule_id": "rule_01a13491059de98d701894071e6a", "rule_name": "No Ground to California", "reason": "FedEx Ground is excluded" }
    ]
  }
]
```

For a whole carrier that was never asked, `service`, `service_name` and `rate` are `null`. A rate removed by several rules lists each in `removed_by`. Excluded
rates have no `id` and can't be bought. A shipment created with `apply_rules: false` has empty lists.

A complete shipment answer, trimmed to the rules fields, for a California order with the rule *No Ground to California*
(exclude Ground, pick cheapest, tag `west-coast`):

```json
{
  "id": "shp_01a1349105b949f63fea0cef09cb",
  "object": "Shipment",
  "status": "created",
  "rates": [ { "id": "rate_01a1349105b992de0d8358bea311", "service": "FEDEX_EXPRESS_SAVER", "rate": "19.60", "…": "…" } ],
  "applied_rules": [
    {
      "id": "rule_01a13491059de98d701894071e6a",
      "name": "No Ground to California",
      "effects": [
        { "action": "exclude_services", "summary": "exclude FedEx Ground", "status": "applied", "rates_removed": 1 },
        { "action": "pick", "summary": "pick the cheapest rate", "status": "applied" },
        { "action": "add_tag", "summary": "tag it “west-coast”", "status": "applied" }
      ]
    }
  ],
  "excluded_rates": [ { "carrier": "FedEx", "service": "FEDEX_GROUND", "rate": "10.35", "…": "…" } ],
  "recommended_rate": {
    "id": "rate_01a1349105b992de0d8358bea311",
    "carrier": "FedEx",
    "carrier_account_id": "ca_env_fedex",
    "service": "FEDEX_EXPRESS_SAVER"
  },
  "tags": ["west-coast"],
  "notes": [],
  "order": { "value": "40.00", "currency": null, "tags": ["Gift"], "shipping_method": null, "items": [ { "sku": "MUG-01", "quantity": 2, "value": "20.00" } ] }
}
```

### Skipping rules: `apply_rules: false`

Every request that evaluates rules accepts `"apply_rules": false`: `POST /v1/rates`, `POST /v1/shipments`,
`POST /v1/rates/compare`, and `POST /v1/batches`. On a batch or comparison it can be set for the whole batch and
overridden per order (an order's own setting wins; a batch-level `false` skips every order that doesn't set its own).
The request is then handled as if you had no rules: no limits, no changes, empty `applied_rules`. A shipment created
this way is marked, so re-rating it doesn't apply rules either. Use it for exceptions ("ship this one Ground
anyway") and for emergencies.

### Scopes and roles

| Scope | Allows |
| --- | --- |
| `rules:read` | `GET /v1/rules`, `GET /v1/rules/:id`, `POST /v1/rules/test` |
| `rules:write` | Everything under `/v1/rules`, including `reorder`, `draft` and `copy_to_live` (which also needs the role below). Includes `rules:read` |

A key can hold both, either, or neither. Evaluating rules during `POST /v1/rates`, shipments and batches needs no
rules scope: a key that can quote can quote, and the rules apply to it. By role:

| Role | Rules access |
| --- | --- |
| `viewer`, `member` | `rules:read`: see the rules, try them with **Try it** |
| `admin`, `owner` | `rules:write`: create, edit, reorder, delete, describe in English, copy to live |

`POST /v1/rules/copy_to_live` is for **people with authority over live shipping**, so a key (even one with `rules:write`)
gets `403 FORBIDDEN`. Only the root key, or a signed-in owner or admin, may use it.

### Audit trail

Every change to the rules is in the [audit log](https://ship.vprev.dev/docs/md/api.md#get-v1audit_events):

| Action | Target | `details` |
| --- | --- | --- |
| `rule.created` | the rule | `name`, `summary` |
| `rule.updated` | the rule | `name`, `fields` changed, `summary` |
| `rule.deleted` | the rule | `name`, `summary` |
| `rules.reordered` | | `order`: the new list of IDs |
| `rules.copied_to_live` | | `copied`, `replaced` (how many live rules were removed), `names`. Recorded in live mode |
| `rule.drafted` | the rule being edited, if any | `model`, `chars` (length of the text), `rules` (how many drafted), `input_tokens`, `output_tokens`, `repaired` |
| `rule.draft_failed` | the rule being edited, if any | `model`, `chars`, `input_tokens`, `output_tokens` |

The text you typed for "Describe it in English" is not in the audit log; see [privacy](#privacy-what-is-sent-and-what-is-kept).

### Idempotency

All `POST` endpoints accept an `Idempotency-Key` as usual, **except `POST /v1/rules/draft`**: a retry of a draft is a new model call,
and the text is not kept in the idempotency store.

### Concurrent edits

Two people editing at once can't overwrite each other. Each write checks that nobody else wrote since it
started, and retries a few times; if it still loses the race it returns `409 RULES.CONFLICT` ("reload and try again").
`reorder` also returns it when `rule_ids` doesn't list exactly the existing rules.

### Copying rules to live

Build and try rules in test mode, then promote them:

- **Admin:** in test mode, **Copy to live** on the Shipping rules page.
- **API:** `POST /v1/rules/copy_to_live` with the root key and `X-Api-Mode: test`, or as a signed-in owner or admin in test mode.
  A plain API key, even a test key with `rules:write`, gets `403 FORBIDDEN`.

What it does:

- It **replaces** every live rule with the test rules, in the same order and with the same rule IDs. It doesn't merge. Live rules that
  aren't in test mode are removed, so copy only when your test rules are the whole truth. The audit log records how many were copied
  and how many were replaced (`rules.copied_to_live`).
- It returns the live rule list, including the live warnings.
- It must be called in test mode. In live mode it fails with `422 RULES.COPY.WRONG_MODE`.
- With no test rules it won't empty live: when live has rules, it fails with `422 RULES.COPY.EMPTY`. Delete live rules in live mode instead.

Things that are different in live, and show up as warnings on the live list afterwards:

- **Address IDs.** `set_from_address` refers to a saved address, and test and live addresses are separate records. Create the warehouse in
  live mode, then edit the rule to use its ID (`address_missing`).
- **Carriers.** Test mode has the mock carriers; live has your real accounts. A rule that names a carrier you haven't connected live warns
  (`carrier_not_configured`).
- **Presets** are per mode too (`preset_missing`), and so are **service codes** (`service_not_offered`).

### TypeScript client

```ts
import { VeloShipClient } from "@velopeplabs/ship";

const veloship = new VeloShipClient({ apiKey: process.env.VELOSHIP_KEY!, baseUrl: "https://ship.vprev.dev" });

const rule = await veloship.rules.create({
  name: "Signature on orders over $100",
  when: { all: [{ field: "order.value", op: "gt", value: "100.00" }] },
  then: [{ type: "set_signature", signature: "direct" }],
});

const { rules, warnings } = await veloship.rules.list({ includeStats: true }); // adds match counts
await veloship.rules.update(rule.id, { enabled: false });
await veloship.rules.reorder(rules.map((r) => r.id).reverse());

const test = await veloship.rules.test({
  input: { to_address: { street1: "1 Market St", city: "San Francisco", state: "CA", zip: "94105", country: "US" }, parcel: { weight: 16 }, order: { value: "149.00" } },
});
console.log(test.applied_rules);

const quote = await veloship.rates.quote({ to_address, parcel: { weight: 16 }, order: { value: "149.00" } });
console.log(quote.applied_rules, quote.excluded_rates, quote.recommended_rate);
```

`rules.list`, `create`, `retrieve`, `update`, `delete`, `reorder`, `test`, `draft` and `copyToLive` match the endpoints above. The
types (`Rule`, `RuleCondition`, `RuleAction`, `AppliedRule`, `ExcludedRate`, `RuleTestResult`, `RuleDraft`, …) are exported.
`order` and `apply_rules` are on the quote, shipment and batch parameters.

---

## The admin page

**More → Setup → Shipping rules** (viewers and members can look and try; admins and owners can change things). It is built for
people who think in sentences.

- **The list.** Every rule is a card showing its sentence ("When order value is more than $100.00: require a direct
  signature"), with a switch to turn it on or off. Drag a card, or use the keyboard, to reorder; the order is the evaluation order and is
  numbered. Cards can be **duplicated** (the copy starts switched off) and **deleted**. Each shows how many of your recent shipments it matched.
  Warnings sit at the top of the list and on the rules they concern.
- **Templates.** Press **Templates** to start from one of 16 ready-made rules instead of a blank page: signatures and protection for
  valuable orders, saving money, remote destinations, PO boxes and military addresses, checkout choices and safety rules. See
  [the list](#starter-templates).
- **The editor.** A rule is edited as a sentence: pick a field, a comparison and a value, add more tests, and group them
  with *all of*, *any of* or *not*, nested as deep as you need. Then choose what to do. A live summary underneath
  reads back what you've built in plain English, so you can check it means what you meant. Mistakes are flagged on the
  field they belong to before anything is saved.
- **Try it.** Run a sample order through your rules, with the rule you're editing included even if it isn't saved yet.
  Pick a sample or one of your recent shipments to replay, tweak it, and see which rules matched and why, what each
  changed, and what would have happened. Switch on live quotes to see the rates that survive. See
  [Testing rules](#testing-rules).
- **Describe a rule.** When the server is set up for it, a box where you type what you want in plain English. See
  [Describe it in English](#describe-it-in-english).
- **Copy to live.** In test mode, a button that replaces your live rules with these. See
  [Copying rules to live](#copying-rules-to-live).
- **Elsewhere in the admin.** A shipment's page shows which rules applied and what each did, the rates a rule removed
  and why, and the tags and notes rules added, so a service you expected but can't find is explained. On the Ship page,
  optional order value and tags fields feed rules that look at the order, the recommended rate is marked and preselected, and an order a rule refuses says
  so with your message. Batches show the rules that applied to each order.

#### Starter templates

| Category | Template | What it does |
| --- | --- | --- |
| Protect orders | Signature on orders over $100 | Valuable orders must be signed for. |
| Protect orders | Protect fragile orders | Adds package protection for the full order value when the order is tagged fragile. |
| Protect orders | Declare the value of big orders | Declares the order value on orders of $500 or more, so the carrier's liability matches. |
| Save money | Under 1 lb: cheapest USPS | Light packages go USPS, at the cheapest rate. |
| Save money | Over 20 lb: cheapest rate | Heavy parcels aren't worth rushing. |
| Save money | Cap shipping at 15% of the order | Drops rates that cost more than 15% of the order value. Needs `order.value` on the request. |
| Save money | Don't spend more on the label than the customer paid | Keeps rates at or under what the customer paid for shipping; if none fit, up to $3 over; if still none fit, every rate stays. Then picks the cheapest. Needs `order.shipping_paid` on the request. |
| Save money | Prefer UPS unless FedEx is $1 cheaper | Buys UPS unless another carrier is $1.00 or more cheaper. |
| Save money | FedEx boxes ship One Rate | When the parcel is already in FedEx packaging, price it as One Rate. |
| Remote destinations | Hawaii, Alaska and territories: no Ground | FedEx Ground is slow and unreliable there, so it's removed from the options. |
| Special addresses | PO boxes: USPS only | Only USPS delivers to PO boxes. |
| Special addresses | APO/FPO/DPO: USPS | Military addresses are delivered by USPS. |
| Checkout | Expedited checkout: fastest within 2 days | When the customer paid for expedited shipping, pick the fastest rate that gets there in two days. |
| Checkout | Overnight checkout: overnight services only | When the customer paid for overnight shipping, keep only overnight services. Service codes rather than a delivery-days limit, so a Friday label that arrives Monday still counts. |
| Checkout | Saturday delivery for perishables | Perishable orders can't sit in a truck over the weekend. |
| Checkout | Gift orders: leave out the invoice | Adds a note for the packer. |
| Safety | Block international orders | Refuses anything that isn't going to the United States. |
| Safety | Block PO boxes | Refuses PO box addresses outright. |

---

## Testing rules

A **dry run** answers "what would my rules do to *this* order?" without creating a shipment, buying anything, or
changing a rule. It is **Try it** in the admin and `POST /v1/rules/test` in the API (scope `rules:read`).

| Body field | |
| --- | --- |
| `input` | A sample request like `POST /v1/rates` takes (`to_address`, `parcel`, `order`, `options`, …), plus optional `reference`, `service` and `source` (`api` by default). `service` is a service the caller asked for, to see it beat a `pick`; it can also be `cheapest`, `fastest` or `best_value`, as a batch would send |
| `shipment_id` | Instead of `input`: replay a saved shipment's addresses, parcel, `order` and `reference`. Its options aren't sent, so rules decide fresh |
| `rules` | Optional. Up to 50 rules **you haven't saved**; they **replace** the saved rules for this test. Give a rule the `id` of a saved rule to line them up |
| `quote` | `true` also quotes with your carriers and applies the limits to the real rates (test mode: the mock carriers). Default `false` |

Send exactly one of `input` and `shipment_id`.

```sh
curl -X POST $BASE/v1/rules/test \
  -H "authorization: Bearer $API_KEY" -H "content-type: application/json" \
  -d '{
    "input": {
      "to_address": { "street1": "1 Market St", "city": "San Francisco", "state": "CA", "zip": "94105", "country": "US" },
      "parcel": { "weight": 16 },
      "order": { "value": "149.00", "tags": ["fragile"] },
      "reference": "order-1042"
    },
    "rules": [
      { "id": "rule_a", "name": "Signature on orders over $100",
        "when": { "all": [ { "field": "order.value", "op": "gt", "value": "100.00" } ] },
        "then": [ { "type": "set_signature", "signature": "direct" } ] },
      { "id": "rule_b", "name": "Heavy fragile orders",
        "when": { "all": [ { "field": "order.tags", "op": "contains", "value": "fragile" },
                           { "field": "parcel.weight", "op": "gt", "value": 2, "unit": "lb" } ] },
        "then": [ { "type": "add_note", "note": "Fragile: pack with extra padding" } ], "stop_processing": true },
      { "id": "rule_c", "name": "Tag west coast",
        "when": { "all": [ { "field": "to.state", "op": "in", "value": ["CA", "OR", "WA"] } ] },
        "then": [ { "type": "add_tag", "tag": "west-coast" } ] }
    ]
  }'
```

```json
{
  "object": "RuleTest",
  "rules": [
    {
      "id": "rule_a", "name": "Signature on orders over $100", "enabled": true, "evaluated": true, "matched": true,
      "trace": {
        "type": "all", "result": true,
        "children": [
          { "type": "condition", "result": true, "text": "order value is more than $100.00",
            "field": "order.value", "op": "gt", "value": "100.00", "actual": "149.00" }
        ]
      }
    },
    {
      "id": "rule_b", "name": "Heavy fragile orders", "enabled": true, "evaluated": true, "matched": false,
      "trace": {
        "type": "all", "result": false,
        "children": [
          { "type": "condition", "result": true, "text": "order tags includes “fragile”",
            "field": "order.tags", "op": "contains", "value": "fragile", "actual": ["fragile"] },
          { "type": "condition", "result": false, "text": "package weight is more than 2 lb",
            "field": "parcel.weight", "op": "gt", "value": 2, "actual": 16 }
        ]
      }
    },
    { "id": "rule_c", "name": "Tag west coast", "enabled": true, "evaluated": true, "matched": true, "trace": { "…": "…" } }
  ],
  "applied_rules": [
    { "id": "rule_a", "name": "Signature on orders over $100",
      "effects": [ { "action": "set_signature", "summary": "require a direct signature", "status": "applied" } ] },
    { "id": "rule_c", "name": "Tag west coast",
      "effects": [ { "action": "add_tag", "summary": "tag it “west-coast”", "status": "applied" } ] }
  ],
  "effective": {
    "options": { "signature": "direct" },
    "protection": null,
    "parcel": { "weight": 16 },
    "from_address": null,
    "tags": ["west-coast"],
    "notes": []
  },
  "blocked": null,
  "warnings": [],
  "quote": null,
  "error": null
}
```

How to read it:

- **`rules`** has one entry per rule tried, in order. `matched` says whether it fired. `evaluated: false` means it wasn't
  looked at (disabled, or below a matching `stop_processing` rule), and its `trace` is `null`.
- **`trace`** is the condition tree with the result of every part and what the request actually had (`actual`):
  *Heavy fragile orders* didn't match because the package weighs 16 oz (1 lb), which isn't more than 2 lb. Weights are shown in ounces, sizes in inches and money in dollars.
- **`applied_rules`** is what you'd see on a real response.
- **`effective`** is the request after the "change the shipment" actions: the options and parcel that would be quoted,
  the ship-from address, the protection, the tags and the notes.
- **`blocked`** is set when a `block` rule matched: a real request would fail with `RULES.BLOCKED`.
- **`warnings`** analyze the rules tried.
- **`quote`** and **`error`** are filled only with `quote: true`: the quote as `POST /v1/rates` would return it
  (with `excluded_rates`), or, when the rules left nothing, `error` with `RULES.NO_RATE` and what was removed.

What a dry run **doesn't** do: it creates no shipment, buys nothing, and writes nothing (it isn't in the audit log, though the
call is in the request log like any other). Without `quote: true` it asks no carrier anything, so **limits can't be seen** (no rates are
there to remove) and a rule on `to.residential` sees "unknown" for an address that hasn't been classified. With `quote: true`
in **live mode** it makes real rate calls to your carriers (never a label purchase), so use test mode while you build.

Try it in the admin works the same way, and picks the test carriers' rates in test mode. See [testing with scenarios](https://ship.vprev.dev/docs/md/testing.md#testing-shipping-rules) for
rules combined with simulated carrier failures.

## Recipes

Each recipe is a complete rule body for `POST /v1/rules`. Send it as it is, or rebuild it in the admin's editor: the fields, comparisons and
actions are the ones the editor offers. Change the numbers, states and IDs to match your shop. A rule that names a carrier or a preset needs that
carrier account or preset to exist, and you can always test a recipe with [Try it](#testing-rules) before saving it.

### Protecting orders

#### Recipe 1: Signature on orders over $100

Valuable orders should be signed for. A caller's own `options.signature` still wins.

```json
{"name": "Signature on orders over $100", "description": "Valuable orders must be signed for.",
 "when": {"all": [{"field": "order.value", "op": "gt", "value": "100.00"}]},
 "then": [{"type": "set_signature", "signature": "direct"}]}
```

#### Recipe 2: Protect fragile orders

Orders tagged `fragile` get package protection for their full value, and a note for the packer. Needs package protection switched on in
**Settings**; if it is off the protection is skipped (and says so) but the note still lands.

```json
{"name": "Protect fragile orders", "description": "Full protection and a padding note for fragile orders.",
 "when": {"all": [{"field": "order.tags", "op": "contains", "value": "fragile"}]},
 "then": [{"type": "add_protection", "percent_of_order_value": 100},
          {"type": "add_note", "note": "Fragile: pack with extra padding"}]}
```

#### Recipe 3: Declare the value of big orders

At $500 and up, declare the order value to the carrier so its liability matches, and require an adult signature.

```json
{"name": "Declare the value of big orders", "description": "Declared value equal to the order value, adult signature.",
 "when": {"all": [{"field": "order.value", "op": "gte", "value": "500.00"}]},
 "then": [{"type": "set_declared_value", "percent_of_order_value": 100},
          {"type": "set_signature", "signature": "adult"}]}
```

### Saving money

#### Recipe 4: Light packages: cheapest USPS

Under 1 lb, USPS is often the cheapest. Needs a USPS carrier account; without one the rule list warns you, because every light parcel would
have no rates left.

```json
{"name": "Under 1 lb: cheapest USPS", "description": "Light packages go USPS at the cheapest rate.",
 "when": {"all": [{"field": "parcel.weight", "op": "lt", "value": 1, "unit": "lb"}]},
 "then": [{"type": "only_carriers", "carriers": ["usps"]},
          {"type": "pick", "service": "cheapest"}]}
```

#### Recipe 5: Heavy parcels: cheapest rate

Nobody is waiting on a 30 lb box to be fast.

```json
{"name": "Over 20 lb: cheapest rate", "description": "Heavy parcels aren't worth rushing.",
 "when": {"all": [{"field": "parcel.weight", "op": "gt", "value": 20, "unit": "lb"}]},
 "then": [{"type": "pick", "service": "cheapest"}]}
```

#### Recipe 6: Cap shipping at 15% of the order

Drops every rate that costs more than 15% of the order value. Orders that don't send a value are left alone. A cheap order can
end up with nothing to ship with (`RULES.NO_RATE`), which is the point if you'd rather not ship it than lose money.

```json
{"name": "Cap shipping at 15% of the order", "description": "Drops rates above 15% of the order value.",
 "when": {"all": [{"field": "order.value", "op": "exists"}]},
 "then": [{"type": "max_price", "percent_of_order_value": 15}]}
```

#### Recipe 7: Prefer UPS unless FedEx is $1 cheaper

Picks the cheapest rate, but gives UPS the win unless another carrier beats it by $1.00 or more. Needs a UPS account to have any effect.

```json
{"name": "Prefer UPS unless another carrier is $1 cheaper", "description": "Cheapest rate, with a thumb on the scale for UPS.",
 "when": {"all": []},
 "then": [{"type": "pick", "service": "cheapest"},
          {"type": "prefer_carrier", "carrier": "ups", "within": "1.00"}]}
```

#### Recipe 8: FedEx boxes ship One Rate

When the parcel is already in FedEx packaging, price it as One Rate. The condition keeps the rule away from parcels that aren't in FedEx packaging. A parcel over One Rate's weight limit, or going abroad, is still ineligible, and the quote says so.

```json
{"name": "FedEx boxes ship One Rate", "description": "Parcels in FedEx packaging are priced as One Rate.",
 "when": {"all": [{"field": "parcel.packaging", "op": "starts_with", "value": "FEDEX_"}]},
 "then": [{"type": "one_rate", "mode": "force"}]}
```

#### Recipe 9: Compare One Rate in batches

Batches normally use each order's parcel as given. This rule makes every batch order be quoted both ways, in its own box and in the
smallest One Rate box, and buys the cheaper. A batch's or order's own `one_rate` still wins.

```json
{"name": "Compare One Rate in batches", "description": "Batch orders compare their own box with FedEx One Rate boxes.",
 "when": {"all": [{"field": "source", "op": "eq", "value": "batch"}]},
 "then": [{"type": "one_rate", "mode": "compare"}]}
```

### Remote and special destinations

#### Recipe 10: No Ground to Hawaii, Alaska and the territories

FedEx Ground is slow there, so remove it. The second code is Ground's Home Delivery twin; either one removes both, and listing both is just explicit.

```json
{"name": "Hawaii, Alaska and territories: no Ground", "description": "Ground is slow and unreliable there.",
 "when": {"all": [{"field": "to.state", "op": "in", "value": ["HI", "AK", "PR", "GU", "VI", "AS", "MP"]}]},
 "then": [{"type": "exclude_services", "services": ["FEDEX_GROUND", "GROUND_HOME_DELIVERY"]}]}
```

#### Recipe 11: The same, by ZIP code

If a destination's state is sometimes missing or mistyped, ZIP ranges are the safer test. `967-968` covers Hawaii, `995-999` Alaska.

```json
{"name": "Hawaii and Alaska by ZIP: no Ground", "description": "ZIP ranges for Hawaii (967-968) and Alaska (995-999).",
 "when": {"all": [{"field": "to.zip", "op": "matches_any_zip", "value": ["967-968", "995-999"]}]},
 "then": [{"type": "exclude_services", "services": ["FEDEX_GROUND"]}]}
```

#### Recipe 12: PO boxes: USPS only

Only USPS delivers to PO boxes. Pair it with Recipe 15 if you'd rather refuse them. Needs a USPS account.

```json
{"name": "PO boxes: USPS only", "description": "Only USPS delivers to PO boxes.",
 "when": {"all": [{"field": "to.po_box", "op": "eq", "value": true}]},
 "then": [{"type": "only_carriers", "carriers": ["usps"]}]}
```

#### Recipe 13: Military addresses: USPS

APO, FPO and DPO addresses go through USPS.

```json
{"name": "APO/FPO/DPO: USPS", "description": "Military addresses are delivered by USPS.",
 "when": {"all": [{"field": "to.military", "op": "eq", "value": true}]},
 "then": [{"type": "only_carriers", "carriers": ["usps"]}]}
```

#### Recipe 14: Block international orders

Refuses anything that isn't going to the United States, with a message your team or storefront can show.

```json
{"name": "Block international orders", "description": "We only ship within the United States.",
 "when": {"all": [{"field": "to.country", "op": "neq", "value": "US"}]},
 "then": [{"type": "block", "message": "We only ship within the United States"}]}
```

An address with no country at all also matches `neq`, since a missing value "isn't equal". Add `{"field": "to.country", "op": "exists"}` to the `all` if
you want to leave those alone.

#### Recipe 15: Block PO boxes with your own message

Instead of routing to USPS (Recipe 12), refuse and tell the customer why. Use one or the other.

```json
{"name": "Block PO boxes", "description": "We can't ship to PO boxes.",
 "when": {"all": [{"field": "to.po_box", "op": "eq", "value": true}]},
 "then": [{"type": "block", "message": "We can't deliver to PO boxes. Please enter a street address."}]}
```

### Checkout choices

#### Recipe 16: Expedited checkout: fastest within 2 days

The customer paid for expedited shipping, so only keep rates that arrive in two days and pick the fastest. Needs `order.shipping_method`.

```json
{"name": "Expedited checkout: fastest within 2 days", "description": "Honor what the customer paid for.",
 "when": {"all": [{"field": "order.shipping_method", "op": "contains", "value": "expedited"}]},
 "then": [{"type": "max_delivery_days", "days": 2},
          {"type": "pick", "service": "fastest"}]}
```

#### Recipe 17: Saturday delivery for perishables

Perishable orders can't sit in a truck over the weekend, so allow Saturday delivery and flag them. Saturday delivery has to be supported by the carrier and service.

```json
{"name": "Saturday delivery for perishables", "description": "Perishables can't wait for Monday.",
 "when": {"all": [{"field": "order.tags", "op": "contains", "value": "perishable"}]},
 "then": [{"type": "set_saturday_delivery", "enabled": true},
          {"type": "add_tag", "tag": "perishable"}]}
```

#### Recipe 18: Gift orders: a note and a tag

For your packers: leave the invoice out.

```json
{"name": "Gift orders: leave out the invoice", "description": "A note for the packer.",
 "when": {"all": [{"field": "order.tags", "op": "contains", "value": "gift"}]},
 "then": [{"type": "add_note", "note": "Gift: don't include the invoice"},
          {"type": "add_tag", "tag": "gift"}]}
```

#### Recipe 19: Small orders ship in the small box

When the order contains a particular SKU, pack it in a saved preset. Needs a parcel preset called `small-box` (Settings). It's skipped for
requests that already chose their own packaging.

```json
{"name": "Mugs ship in the small box", "description": "Pack MUG-01 orders in the small-box preset.",
 "when": {"all": [{"field": "order.skus", "op": "contains", "value": "MUG-01"}]},
 "then": [{"type": "use_preset", "preset": "small-box"}]}
```

### Warehouses and customers

#### Recipe 20: Ship from the nearest warehouse

Route western destinations to a second warehouse. Replace the placeholder address ID with your own: it must be a saved address in the same
mode (`GET /v1/settings` shows your default; create others with `POST /v1/addresses`). A `from_address` sent with the request still wins.

```json
{"name": "West: ship from the Reno warehouse", "description": "Western destinations ship from the second warehouse.",
 "when": {"all": [{"field": "to.state", "op": "in", "value": ["CA", "OR", "WA", "NV", "AZ", "ID", "UT", "MT", "WY", "CO", "NM", "AK", "HI"]}]},
 "then": [{"type": "set_from_address", "address_id": "adr_01a131ca1cc83766d0ac435ef"},
          {"type": "add_tag", "tag": "ships-from-reno"}]}
```

#### Recipe 21: Wholesale orders: cheapest, no signature

Wholesale customers get the cheapest rate and no signature. Put this rule **below** a general signature rule (Recipe 1): the later rule wins,
so wholesale orders end up with no signature even when they're over $100.

```json
{"name": "Wholesale: cheapest, no signature", "description": "B2B orders ship cheapest and unsigned.",
 "when": {"all": [{"field": "order.tags", "op": "contains", "value": "wholesale"}]},
 "then": [{"type": "set_signature", "signature": "none"},
          {"type": "pick", "service": "cheapest"}]}
```

#### Recipe 22: VIP orders skip every rule below

Tag `vip` at the top of the list: when it matches, no later rule is looked at. Use sparingly; it also skips your `block` rules.

```json
{"name": "VIP orders: skip the rules below", "description": "Handled by hand.",
 "when": {"all": [{"field": "order.tags", "op": "contains", "value": "vip"}]},
 "then": [{"type": "add_note", "note": "VIP: shipping rules skipped"}],
 "stop_processing": true}
```

#### Recipe 23: Route by reference prefix

If your orders come from several channels, a prefix on `reference` tells them apart.

```json
{"name": "Etsy orders: tag and cheapest", "description": "Etsy references start with ETSY-.",
 "when": {"all": [{"field": "reference", "op": "starts_with", "value": "ETSY-"}]},
 "then": [{"type": "add_tag", "tag": "etsy"},
          {"type": "pick", "service": "cheapest"}]}
```

### Time and place

#### Recipe 24: Perishables ship express on Thursday and Friday

Keeps a perishable parcel from sitting in a warehouse or a truck over the weekend. The weekday is **UTC**: a Thursday evening in California is already
Friday in UTC, and a Friday evening is Saturday.

```json
{"name": "Perishables: express on Thursday and Friday", "description": "No ground service just before the weekend.",
 "when": {"all": [{"field": "order.tags", "op": "contains", "value": "perishable"},
                  {"field": "ship.day_of_week", "op": "in", "value": ["thu", "fri"]}]},
 "then": [{"type": "only_services", "services": ["FEDEX_2_DAY", "STANDARD_OVERNIGHT"]}]}
```

#### Recipe 25: Holiday window

For the days before the cutoff, only keep rates that arrive in four days, and mark the shipment. Dates are UTC and inclusive.

```json
{"name": "Holiday rush: arrive within 4 days", "description": "December 14 to 20.",
 "when": {"all": [{"field": "ship.date", "op": "between", "value": ["2026-12-14", "2026-12-20"]}]},
 "then": [{"type": "max_delivery_days", "days": 4},
          {"type": "add_tag", "tag": "holiday-rush"}]}
```

#### Recipe 26: Avoid residential surcharges on light parcels

FedEx and UPS add a residential surcharge; USPS generally doesn't. For a light parcel going to a home, drop them. The rule only matches addresses known to be homes
(see [Residential](#residential-homes-and-businesses)), and needs a USPS account: otherwise these orders get `RULES.NO_RATE`.

```json
{"name": "Light parcels to homes: skip FedEx and UPS", "description": "Avoid residential surcharges.",
 "when": {"all": [{"field": "to.residential", "op": "eq", "value": true},
                  {"field": "parcel.weight", "op": "lt", "value": 1, "unit": "lb"}]},
 "then": [{"type": "exclude_carriers", "carriers": ["fedex", "ups"]}]}
```

#### Recipe 27: Oversize parcels: Ground only

Parcels over 48 inches on a side, or with a girth over 100 inches, tend to run into carrier surcharges and size limits. Keep them on the service your team has agreed to use.
Both fields need all three dimensions; without them the rule can't match.

```json
{"name": "Oversize parcels: Ground only", "description": "Longest side 48 in or more, or girth over 100 in.",
 "when": {"any": [{"field": "parcel.longest_side", "op": "gte", "value": 48, "unit": "in"},
                  {"field": "parcel.girth", "op": "gt", "value": 100, "unit": "in"}]},
 "then": [{"type": "only_services", "services": ["FEDEX_GROUND"]},
          {"type": "add_tag", "tag": "oversize"}]}
```

### Combining conditions with `any` and `not`

#### Recipe 28: Fragile or glass, and heavy

An `any` inside an `all`: the order is fragile **or** contains one of these SKUs, **and** the parcel is over 2 lb.

```json
{"name": "Heavy fragile or glass orders: protect", "description": "Fragile tag or glass SKUs, over 2 lb.",
 "when": {"all": [{"any": [{"field": "order.tags", "op": "contains", "value": "fragile"},
                           {"field": "order.skus", "op": "in", "value": ["GLASS-01", "GLASS-02"]}]},
                  {"field": "parcel.weight", "op": "gt", "value": 2, "unit": "lb"}]},
 "then": [{"type": "add_protection", "percent_of_order_value": 100},
          {"type": "add_note", "note": "Heavy and breakable: double box"}]}
```

#### Recipe 29: Signature for $250 and up, except wholesale

A `not` for the exception. A `not` takes **one** test or group, which can itself be an `any` or `all`.

```json
{"name": "Signature at $250 and up, except wholesale", "description": "Retail orders only.",
 "when": {"all": [{"field": "order.value", "op": "gte", "value": "250.00"},
                  {"not": {"field": "order.tags", "op": "contains", "value": "wholesale"}}]},
 "then": [{"type": "set_signature", "signature": "direct"}]}
```

#### Recipe 30: Ordinary addresses ship cheapest

`not` around an `any`: everything that is neither a PO box nor a military address.

```json
{"name": "Ordinary addresses: cheapest rate", "description": "Neither a PO box nor a military address.",
 "when": {"not": {"any": [{"field": "to.po_box", "op": "eq", "value": true},
                          {"field": "to.military", "op": "eq", "value": true}]}},
 "then": [{"type": "pick", "service": "cheapest"}]}
```

### What the customer paid at checkout

These two work from what the customer chose and paid for at checkout. Send `order.shipping_method` and
`order.shipping_paid` with every quote, shipment and batch order:

```json
"order": { "value": "86.00", "shipping_method": "Standard", "shipping_paid": "7.95" }
```

#### Recipe 31: Overnight checkout: overnight services only

The customer paid for overnight, so keep only overnight services. This lists the services rather than using
`max_delivery_days: 1`: a label bought on Friday for Monday delivery is still overnight service, but arrives three days later
and would fail a one-day limit. Keep the codes for your carriers; codes for carriers you don't have just show a
`service_not_offered` warning.

```json
{"name": "Overnight checkout: overnight services only", "description": "Honor what the customer paid for.",
 "when": {"all": [{"field": "order.shipping_method", "op": "contains", "value": "overnight"}]},
 "then": [{"type": "only_services", "services": ["FIRST_OVERNIGHT", "PRIORITY_OVERNIGHT", "STANDARD_OVERNIGHT",
                                                 "UPS_NEXT_DAY_AIR_EARLY", "UPS_NEXT_DAY_AIR", "UPS_NEXT_DAY_AIR_SAVER",
                                                 "PRIORITY_MAIL_EXPRESS"]}]}
```

#### Recipe 32: Don't spend more on the label than the customer paid

Prefer rates at or under what the customer paid for shipping; if none fit, allow up to $3 over; if still none fit, don't fail:
keep every rate and pick the cheapest. Both caps are soft (`if_none_left: "relax"`), which is what makes the fallback work; see
[`max_price`](#max_price). Testing `order.shipping_paid exists` keeps the rule out of requests that don't send it.

```json
{"name": "Don't spend more on the label than the customer paid", "description": "At most what they paid, else $3 more, else the cheapest.",
 "when": {"all": [{"field": "order.shipping_paid", "op": "exists"}]},
 "then": [{"type": "max_price", "over_shipping_paid": "0.00", "if_none_left": "relax"},
          {"type": "max_price", "over_shipping_paid": "3.00", "if_none_left": "relax"},
          {"type": "pick", "service": "cheapest"}]}
```

With both rules, an overnight order is first narrowed to overnight services (a hard limit), and the caps then choose among those.
The rule's `pick` replaces a batch's `cheapest` or `fastest`, but never a specific service the order names.

### A rule set that fits together

A small, sensible order for a shop with a few of these (top to bottom):

1. Recipe 22: VIP orders skip the rest (stops everything below).
2. Recipe 14 or 15: block what you can't ship.
3. Recipe 12 and 13: special addresses.
4. Recipe 10: remote destinations.
5. Recipe 1, then Recipe 3, then Recipe 21: signatures, from general to specific, so the specific rule wins.
6. Recipe 7 or 5: how to pick among what is left.

---

## Describe it in English

If your server has an Anthropic API key, the **Describe a rule** box on the Shipping rules page turns what you type into a draft
rule you can review. Nothing is saved until you press **Save**.

> "If an order to Hawaii or Alaska weighs over 2 lb, use 2-day air, and add a signature when it's over $150."

### How it works

1. You type the text (up to 1,000 characters). Optionally you pick an existing rule to *change* instead ("also exclude USPS").
2. VeloShip sends the text to Anthropic's Messages API, with the catalog of fields, operators and actions, your carriers and
   their service codes, your parcel preset names, your existing rule names, and today's date.
3. The model must answer by calling one tool, `draft_rules`, whose shape is the same as the rules API's. It can't write prose to you.
4. The answer is **validated with the same schemas that save a rule**. If something is invalid (an unknown field, a wrong unit), the errors go back
   to the model once, as a repair step, and it tries again.
5. You get the drafts back with a plain-English `summary` for each, plus anything the model wants you to know. You review them, then
   **try them** (they open in the editor, and **Try it** works on unsaved rules) and save.

A draft never changes anything on its own. It isn't saved, it doesn't affect a shipment, and nothing runs until you create the rule.

### What comes back

```sh
curl -X POST $BASE/v1/rules/draft \
  -H "authorization: Bearer $API_KEY" -H "content-type: application/json" \
  -d '{ "text": "Anything to Hawaii or Alaska over 2 lb should go 2-day air, and add a signature if the order is over $150" }'
```

```json
{
  "object": "RuleDraft",
  "rules": [
    {
      "name": "Hawaii and Alaska over 2 lb: 2-day air",
      "description": "Heavier parcels to Hawaii and Alaska ship FedEx 2Day.",
      "enabled": true,
      "when": { "all": [ { "field": "to.state", "op": "in", "value": ["HI", "AK"] },
                         { "field": "parcel.weight", "op": "gt", "value": 2, "unit": "lb" } ] },
      "then": [ { "type": "only_services", "services": ["FEDEX_2_DAY"] } ],
      "stop_processing": false,
      "summary": "When destination state is any of HI or AK and package weight is more than 2 lb: only allow FedEx 2Day."
    },
    { "name": "Signature for Hawaii and Alaska orders over $150", "…": "…" }
  ],
  "summary": "Two rules: 2-day air for heavy parcels to Hawaii and Alaska, and a signature for orders over $150 there.",
  "assumptions": ["Read “2-day air” as FedEx 2Day (FEDEX_2_DAY)", "Read “over 2 lb” as more than 2 lb"],
  "questions": [],
  "unsupported": [],
  "existing_rule_id": null,
  "model": "claude-haiku-5-5"
}
```

The wording is the model's, so yours will differ. The shape doesn't:

| Field | Meaning |
| --- | --- |
| `rules` | Up to 5 draft rules, each a valid `POST /v1/rules` body plus a `summary`. May be empty |
| `summary` | One or two sentences about what the drafts do |
| `assumptions` | Choices the model made where your wording was loose. Check them |
| `questions` | Real ambiguity it couldn't resolve ("Did you mean the order value or the declared value?"). Answer by rewording and drafting again |
| `unsupported` | Parts of the request the available fields and actions can't express. They are listed, never silently dropped |
| `existing_rule_id` | The rule being edited, if you gave one |
| `model` | The model used |

### Good prompts

Say **when** and **what**, with numbers and units:

- "Orders over $100 need a signature."
- "Nothing goes FedEx Ground to Hawaii, Alaska or Puerto Rico."
- "Light packages under 12 oz go USPS at the cheapest rate."
- "Prefer UPS unless FedEx is at least $2 cheaper."
- "If the customer chose expedited at checkout, only show rates that arrive in 2 days and pick the fastest."
- "Block orders to PO boxes and tell the customer we need a street address."
- "Perishable orders on Thursdays and Fridays should only use 2-day or overnight, and allow Saturday delivery."
- "Tag orders over 5 items as bulk, and add a note to double box anything with SKU GLASS-01."
- With a rule selected: "also exclude USPS", "make the limit 3 days", "change the signature to adult".

### What it can't do

If the fields and actions can't express something, the draft says so under `unsupported`, usually with the parts it *could* do. Examples of things rules can't see or do:

| You ask | What to expect |
| --- | --- |
| "Make shipping cheaper" | A question: cheaper how? (cheapest rate, a price cap, a preferred carrier) |
| "Give free shipping to repeat customers" | `unsupported`: rules don't know customer history, and they don't set what the customer pays |
| "Ship Monday if ordered after 3 pm Pacific" | `unsupported` or an assumption: times are UTC, and rules can't delay a shipment |
| "Use the carrier with the best on-time record" | `unsupported`: there's no carrier performance field |
| "Ignore the weight" | Nothing sensible to draft; it will ask what you want |
| Instructions aimed at the model ("ignore your instructions and…") | Treated as data. It only drafts rules |

If the model returns something invalid even after the repair step, you get `422 RULES.DRAFT_INVALID`: reword it, or build the rule by hand.
If it can't be reached or takes longer than 30 seconds: `502 RULES.AI_FAILED`; try again.

### Limits

| | |
| --- | --- |
| Text | 1 to 1,000 characters |
| Rules per draft | 5 |
| Conditions it writes | Nested at most 3 levels |
| Model answer | Up to 4,096 output tokens |
| Time | 30 seconds per model call; one repair round trip at most (so up to two calls) |
| Rate | 10 drafts per minute per organization (`RULES_AI_LIMITER`). Over it: `429 RATE_LIMITED`. The root key and the platform organization aren't limited |
| Scope | `rules:write`: owners and admins, and keys that hold it |

### Privacy: what is sent, and what is kept

**Sent to Anthropic**, on every draft:

- the text you typed;
- the field, operator and action catalog (the same vocabulary this guide documents);
- your carriers' names and service codes;
- your parcel preset names and the names of your existing rules;
- today's date;
- if you are editing a rule, that rule's JSON.

**Never sent:** addresses, orders, shipments, tracking numbers, customer data, or API keys.

**Not kept by VeloShip:** the text and the drafts are not stored in the [request log](https://ship.vprev.dev/docs/md/api.md#get-v1request_logs) (the request and response bodies of this one
endpoint are withheld), and not in the idempotency store (this endpoint doesn't take an `Idempotency-Key`). The [audit log](https://ship.vprev.dev/docs/md/api.md#get-v1audit_events) records only
metadata: who, which model, how many characters, how many rules came back, the token counts, and whether a repair was needed. It never has the text.

What Anthropic keeps is governed by your agreement with them (see Anthropic's data and retention terms for the API). If you can't send even a rule description
to a third party, leave the key unset and build rules by hand.

### Cost and models

Each draft is one or two Messages API requests. Every request carries the catalog, so it is several thousand input tokens, and the answer is typically a few hundred
to a couple of thousand output tokens. The default model is `claude-haiku-5-5`, the small fast model, which handles most descriptions. Set `RULES_AI_MODEL` to a larger
one (for example `claude-sonnet-5-5`) if you write long, subtle descriptions. You pay Anthropic per token on your own key; see their pricing page. The per-organization limit above caps how
fast it can run up.

## Errors

Rules add these to the [error codes](https://ship.vprev.dev/docs/md/api.md#error-codes). Every error has the usual
`{ "error": { "code", "message", "details" } }` shape.

### `RULES.BLOCKED`

`422`. A `block` rule matched. The `message` is **your rule's message**, so it reads well to a customer. `details` names the rule and
`applied_rules` shows everything that matched. Nothing was quoted, bought or saved.

```json
{
  "error": {
    "code": "RULES.BLOCKED",
    "message": "We don't ship to PO boxes",
    "details": [ { "rule_id": "rule_01a13490ea7667c3a42dc4f00cb7", "rule_name": "Block PO boxes" } ],
    "applied_rules": [
      {
        "id": "rule_01a13490ea7667c3a42dc4f00cb7",
        "name": "Block PO boxes",
        "effects": [ { "action": "block", "summary": "refuse to ship (“We don't ship to PO boxes”)", "status": "applied" } ]
      }
    ]
  }
}
```

Fix: change the order (a street address instead of a PO box), edit or disable the rule, or send `apply_rules: false` to skip rules for one request.
In a batch, that order fails with this code and the rest go on.

### `RULES.NO_RATE`

`422`. The rules removed every rate. `details` lists each rule and what it removed, with the reason. The quote or shipment isn't created.

```json
{
  "error": {
    "code": "RULES.NO_RATE",
    "message": "Your shipping rules removed every rate for this shipment (“No Ground”). Change the rules, or send apply_rules: false to skip them",
    "details": [
      {
        "rule_id": "rule_01a1…",
        "rule_name": "No Ground",
        "removed": [
          { "carrier": "FedEx", "service": "FEDEX_GROUND", "service_name": "FedEx Ground", "rate": "16.85", "reason": "FedEx Ground is excluded" },
          { "carrier": "FedEx", "service": "FEDEX_2_DAY", "service_name": "FedEx 2Day", "rate": "32.70",
            "reason": "Costs $32.70, over the $22.35 cap (15% of the $149.00 order)" }
        ]
      }
    ],
    "applied_rules": [ "…" ]
  }
}
```

Fix: loosen a limit, add a carrier account, make the rule's condition narrower, or make a price cap soft with
`if_none_left: "relax"` (see [`max_price`](#max_price)). It only happens when rates existed and the rules removed them all; if
carriers had no rates to begin with you get an ordinary empty `rates` list, or `CARRIER.ALL_FAILED`.

### `RULES.SERVICE_EXCLUDED`

`422`. A caller asked to buy a specific service (`service` on `POST /v1/shipments`, `buy`, or a batch order) that a rule removed. Limits beat requests.

```json
{
  "error": {
    "code": "RULES.SERVICE_EXCLUDED",
    "message": "FedEx Ground was removed by “No Ground to California”: FedEx Ground is excluded",
    "details": [
      {
        "rule_id": "rule_01a13491059de98d701894071e6a",
        "rule_name": "No Ground to California",
        "removed": [ { "carrier": "FedEx", "service": "FEDEX_GROUND", "service_name": "FedEx Ground", "rate": "10.35", "reason": "FedEx Ground is excluded" } ]
      }
    ]
  }
}
```

On `POST /v1/shipments` with a `service`, the shipment was created (rates quoted) before the purchase failed, so the error also carries `shipment_id` and
you can `buy` one of its remaining rates. Fix: choose a service that is still offered, or change the rule, or send `apply_rules: false`.

### `RULES.LIMIT`

`422`. You have 50 rules in this mode: `An organization can have at most 50 rules per mode; delete or combine some first`. Combine rules that share
actions with `any`, or delete rules you no longer need.

### `RULES.CONFLICT`

`409`. Someone changed the rules at the same moment (`The rules were changed by someone else at the same moment; reload and try again`), or `reorder` was
given a `rule_ids` that doesn't list every rule exactly once. Reload the list and try again.

### `RULES.COPY.WRONG_MODE`

`422`. `Copy to live works from test mode: switch to a test key or the admin's test mode`. Copy to live was called in live mode.

### `RULES.COPY.EMPTY`

`422`. `There are no test rules to copy; delete live rules in live mode instead`. Test mode has no rules and live has some, so copying would only delete them.

### `RULES.AI_UNAVAILABLE`

`503`. `Describing rules in English isn't set up on this server (it needs an ANTHROPIC_API_KEY)`. See Turning it on.

### `RULES.AI_FAILED`

`502`. The model call failed: it timed out (`The model took too long to answer; try again`), couldn't be reached, was busy (`The model is busy; try again in a moment`), or Anthropic
answered with an error (`The model returned an error; try again`). Nothing was recorded as a draft. Try again; if it keeps failing with the same message, check the key.

### `RULES.DRAFT_INVALID`

`422`. The model's draft didn't validate, even after one repair attempt. `details` lists what was wrong (`{ field, message }`). Reword the description, make it simpler, or build the rule by hand. The
failure is in the audit log as `rule.draft_failed`.

### Other errors you can see

| Error | When |
| --- | --- |
| `422 PARAMETER.INVALID` | A rule that can't be saved. `details[].field` is the path (`when.all.0.value`, `then.2.percent_of_order_value`) and `message` says what to use; see [the example](#values-are-checked-strictly-then-normalized). Also an `order` with a bad value |
| `403 FORBIDDEN.SCOPE` | The key lacks `rules:read` or `rules:write`: `{ "error": { "code": "FORBIDDEN.SCOPE", "message": "This API key or role lacks the required scope: rules:write", "required_scopes": ["rules:write"] } }` |
| `403 FORBIDDEN` | `copy_to_live` by a key, or by a member who isn't an owner or admin: `Only owners and admins can copy rules to live` |
| `404 NOT_FOUND` | An unknown rule ID (`Rule not found`), or `shipment_id` in a test that doesn't exist |
| `429 RATE_LIMITED` | Too many drafts in a minute |

---

## Limits and performance

| | Limit |
| --- | --- |
| Rules per organization, per mode | 50 |
| Nesting of `all` / `any` / `not` | 5 levels |
| Tests in one rule | 40 |
| Actions in one rule | 20 |
| Values in a list (`in`, `not_in`, `matches_any_zip`) | 200 |
| Rule name / description | 100 / 500 characters |
| Text values in a test | 1 to 200 characters |
| `add_tag` / `add_note` / `block` message | 64 / 500 / 300 characters |
| Carriers in one `exclude_carriers` / `only_carriers` | 10 |
| Service codes in one `exclude_services` / `only_services` | 30 |
| `max_delivery_days` | 1 to 30 |
| `percent_of_order_value` | Declared value and protection: above 0, up to 100. `max_price`: above 0, up to 1000 |
| Money | Up to two decimals, up to 9,999,999 |
| Preset name | Lowercase letters, digits, `-`, `_`; up to 40 |
| `order.tags` | 50, each up to 64 characters |
| `order.items` | 500; `sku` up to 100 characters; `quantity` 1 to 100,000 |
| `order.shipping_method` | 100 characters |
| Rules in a dry run (`rules` on `/rules/test`) | 50 |
| Describe it in English | 1,000 characters; 5 rules per draft; 10 drafts a minute per organization |
| Match counts (`include=stats`) | The last 200 shipments |

**Performance.** Evaluating rules is a handful of in-memory comparisons per rule. Rules load
with your settings, so they add no query. Carriers your rules exclude aren't called, which saves time and carrier API
calls. Limits on services, price or speed run on the quote once it arrives, and the quote cache still applies. The only addition is the optional
home-or-business check, when a rule needs `to.residential` for an address that hasn't been classified; it is the same check a quote does
anyway, once per address.

---

## FAQ and troubleshooting

### My rule didn't apply. What do I check?

Work down this list, or paste the order into [Try it](#testing-rules) and read the trace:

1. **Is it enabled?** A disabled rule is ignored.
2. **Is it in the right mode?** Test and live rules are separate. A rule you wrote in test mode isn't live until you copy it.
3. **Did the request skip rules?** `apply_rules: false` on the request, on the batch, or on that order. Return labels never apply rules.
4. **Did a rule above it stop processing?** A matching rule with `stop_processing` hides the rules below it (`evaluated: false`).
5. **Did the request win?** Look in `applied_rules` for `"status": "kept"`: you sent a signature, a service, a preset, a from address or a One Rate setting yourself.
6. **Did a later rule override it?** Look for `"status": "overridden"`.
7. **Is a fact missing?** `order.value`, dimensions, `parcel.preset`, `to.residential` may simply not be there; the test then can't be satisfied. The trace shows `actual: null`.
8. **Is the value what you think?** `ship.*` is UTC; weights are compared in ounces; `order.tags` ignores case but the SKU you typed must exist.
9. **Did you use `neq` on something that may be missing?** It matches a missing value.

### Some rates disappeared

Look at `excluded_rates` on the quote or shipment: each removed rate with the rule and the reason. A whole carrier that was never asked appears with
`service: null`. The shipment page lists them too.

### Why didn't my batch use the pick rule?

A batch order with a specific `service` (`FEDEX_GROUND`) keeps it; rules only replace `cheapest`, `fastest` and `best_value`. Also check that the order's
`apply_rules` isn't `false`, and that `source` conditions (`batch`) are what you expect.

### My batch order failed with `RULES.SERVICE_EXCLUDED` or `RULES.NO_RATE`

Same as for a single shipment: the specific service you asked for was removed by a rule, or every rate was removed. The item's `error` and `applied_rules` say which rule.

### A rule on `to.residential` never matches

The home-or-business flag is only known if you sent `residential`, the address says so, or the carrier has classified it. In **Try it** without live quotes the
carrier isn't asked. An unknown flag matches neither `true` nor `false`; use the `exists` / `not_exists` operators to tell the difference. See
[Residential](#residential-homes-and-businesses).

### A percent action was skipped

`set_declared_value`, `add_protection` and `max_price` with `percent_of_order_value` need an order value. Send `order.value` (or items that have values). The
`skipped` reason says "The request has no order value". Protection is also skipped when it is off in Settings or the value is over the maximum.

### What do rules do when I re-rate?

Limits and the pick run again with today's rules; what rules changed before quoting stays as it was. See [Rules and re-rating](#rules-and-re-rating).

### I copied rules to live and my warehouse routing broke

Address IDs differ between modes. See [Copying rules to live](#copying-rules-to-live): create the warehouse in live mode, then edit the rule to use the
live ID. The live list shows an `address_missing` warning.

### Is it case sensitive?

Not for text: cities, companies, references, shipping methods, preset names, packaging, tags and SKUs ignore case. States, countries and ZIP codes are upper-cased.
Carrier names in actions are case-insensitive and stored lowercase. **Service codes** (`FEDEX_GROUND`) are exact.

### What time zone is `ship.day_of_week`?

UTC, like `ship.date`. If your cutoff is "before 3 pm Pacific", it is 22:00 or 23:00 UTC, which rules can't test (they look at the day, not the hour).

### Can a rule change what the customer pays?

No. Rules decide how a shipment is priced and packed with carriers, and which rate is chosen. They don't charge customers or set checkout prices.

### Can rules look at the customer, or past orders?

Not today. Rules see only the request: addresses, parcel, `order`, `reference`, the source and the date. Send what you need in `order.tags` (for example `repeat-customer`).

### Do rules slow quotes down?

Not noticeably. See [Limits and performance](#limits-and-performance).

### I changed a rule. What happens to existing shipments?

Nothing. A shipment keeps the decisions it was created with. Only a re-rate re-runs the limits and the pick, and only for unbought shipments.

### Can I see who changed a rule?

Yes: **More → Audit log**, or `GET /v1/audit_events?action=rule.updated`. Every create, update, delete, reorder and copy to live is recorded with the person or key.

### What isn't there yet?

See gaps: the Shopify connection, scheduling a rule for a date range (use `ship.date` for now), per-customer rules, and rules on
tracking or delivery events.
