Set up shipping rates and tax

Configure what shipping costs over the Shipping API — fixed, free and weight-tiered methods, carriers and tracking links, the market settings behind free-shipping thresholds and delivery windows — and quote a cart with the tax the checkout must not guess.

Shipping on this platform is a set of methods and one call that prices them. This page builds the arrangement over the API: a fixed method, a free one and a method priced by a weight table, the carriers they ship with, the market settings that shape a quote (set in Cockpit, read back over the API), and the POST /v1/shipping/rates call your checkout asks on every change of basket, including what to do when the answer says the tax is unknown.

This is for developers and integrators wiring shipping into a checkout, an ERP sync or their own service. You need a tenant with the Shipping core app, an API key with the shipping.* scope (reading the market policy in step 3 has no documented scope yet, so a development key with * covers it; see which scope covers which app), and, to get a tax rate back, a market with tax classes, because the tax on a rate comes from the buyer's market. Prices work without one; only the tax comes back unknown. New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. Every call goes through the API gateway with the same two headers. Make them from your backend and keep the key out of the browser:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

What the Shipping App does not do

Read this first, because the name suggests more than the App covers.

  • It prices and describes shipping. It does not ship. There is no shipment entity in the Shipping App, and no carrier integration: nothing books a pickup, buys a label or reads a parcel's status from a carrier (as of 2026-09).
  • Shipments belong to orders. Booking goods out is an Orders action, and Ship, cancel and return an order walks it, with the stock and money steps that go with it. Shipping only supplies the tracking link for the carrier and the code you stored.
  • A carrier is a record, not a connection. It holds a tracking template, a service level, transit days and a pickup cut-off. It never changes a price. A method's price stays the same whichever carrier carries it.

1. Create the shipping methods

A method is the line the buyer picks in the checkout, and it owns the price. A fresh tenant already has three seeded, enabled methods: standard (4.90, free at 100 unless a shop-wide threshold was already set), express (9.90) and pickup (free). If you delete one, POST /v1/shipping/methods/defaults brings it back (it also re-seeds any deleted seeded carrier and fills empty carrier columns, and never overwrites a value you set). List what is there before you add your own:

List the methods
curl -s "https://api.revenexx.com/v1/shipping/methods?order=position.asc" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '.items[] | {code, pricing_type, price, enabled}'

There are three pricing models. Create one of each to see the difference.

Fixed charges one price for every basket. It is quoted only in the countries you list, and free above 150 in order value:

Create a fixed method
FIXED_METHOD_ID=$(curl -s -X POST https://api.revenexx.com/v1/shipping/methods \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "parcel-eu",
    "name": "Parcel",
    "pricing_type": "fixed",
    "price": 4.90,
    "currency": "EUR",
    "countries": ["DE", "AT"],
    "free_above": 150,
    "eta_days_min": 3,
    "eta_days_max": 5,
    "enabled": true,
    "position": 10
  }' | jq -r .id)

Free has no price at all, for pickup or a promotion. Send { "code": "pickup-store", "name": "Pick up in store", "pricing_type": "free", "enabled": true }. The price field is ignored on a free method.

Matrix looks the price up in a table of tiers over a measure. Create the method first, then fill the table:

Create a matrix method
METHOD_ID=$(curl -s -X POST https://api.revenexx.com/v1/shipping/methods \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "parcel-by-weight",
    "name": "Parcel by weight",
    "pricing_type": "matrix",
    "matrix_basis": "weight",
    "quote_above": 500,
    "enabled": true,
    "position": 20
  }' | jq -r .id)

matrix_basis is the measure: weight, quantity, order_value, or attribute for any figure you name in matrix_attribute (say pallet_spots). For an attribute matrix your checkout supplies the number, summed over the basket, in the quote request's attributes map (step 4). Leave it out and the method uses the market's default basis, which is weight unless the market changed it (step 3).

Three traps in this step:

  • A new method is disabled. enabled defaults to false. A disabled method is never quoted; it shows up in the excluded list of a quote.
  • A matrix method with no tiers is left out of the quote, with the reason matrix has no rate tiers configured. Every new matrix starts in that state on purpose: the App never invents a price ladder for you. The two exceptions are a free_above threshold or a quote_above measure that applies first, which still answer.
  • free_above beats the pricing model, matrix included. At or above the threshold the method costs 0.

Fill the tier table

A tier is a from_value and a price. The winning tier is the one with the highest from_value at or below the measured value, so a weight of exactly 10 is priced by the tier at 10, and the last tier has no upper bound. The price is charged in full for the whole consignment. It is a lookup table, not a rate per kilogram.

The quickest way to lay down an evenly stepped table is the ladder route. This one says "0 to 30, every 5, 4.90 plus 2 a step":

Generate the table
curl -s -X POST "https://api.revenexx.com/v1/shipping/methods/$METHOD_ID/tiers/ladder" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "from_value": 0, "to_value": 30, "step": 5, "base_price": 4.90, "step_price": 2 }'

It answers with the tiers as stored and a basis field naming the measure the numbers are in. Check that, because a ladder is typed as bare numbers and "0 to 30" means kilograms, items or euros depending on the method. Tiers at 0, 5, 10 and up to 30 cost 4.90, 6.90, 8.90 and up to 16.90.

The ladder refuses more than 200 tiers with a 400. It replaces the method's existing table unless you send "replace": false. If your table is not evenly stepped, PUT /v1/shipping/methods/{method_id}/tiers replaces the whole table with the tiers array you send ({ "tiers": [ { "from_value": 0, "price": 4.90 }, … ] }), and POST …/tiers adds one row. Either write returns 409 when two tiers share a from_value. Both PUT and the ladder discard the ids of the rows they replace, and neither is atomic: they delete the old table before writing the new one, so a rejected write can leave the method with a partial or empty table, which quotes nothing. Check the array for duplicate from_value entries before you send it, and re-read the table after a failure. Start every weight table at 0: a measure below the lowest tier has no tier to price it and the method is left out of the quote.

Switching a method from matrix to fixed keeps the table. It stops being read, and switching back quotes the old prices again.

The field-by-field reference is on Shipping rates.

2. Point the methods at carriers

List the carriers the tenant has. A fresh tenant has four seeded and active: dhl, dpd, gls and ups.

List the carriers
curl -s "https://api.revenexx.com/v1/shipping/carriers" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '.items[] | {id, code, status, tracking_url_template}'

Attach a seeded carrier to a method with carrier_id, the id from the list above. The method then inherits the carrier's transit days, pickup cut-off, handling days and tracking template wherever it states none of its own:

Attach a carrier
curl -s -X PUT "https://api.revenexx.com/v1/shipping/methods/$FIXED_METHOD_ID" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "carrier_id": "<CARRIER_ID>" }'

For a regional courier the seed does not cover, create the carrier yourself. The tracking_url_template carries a {tracking_code} slot, and {postal_code} and {country} are also filled in when you send them:

Create a carrier
curl -s -X POST https://api.revenexx.com/v1/shipping/carriers \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "regio-courier",
    "name": "Regio Courier",
    "tracking_url_template": "https://track.example.com/?n={tracking_code}",
    "eta_days_min": 1,
    "eta_days_max": 2,
    "handling_days": 1,
    "cutoff_time": "14:00",
    "countries": ["DE"]
  }'

cutoff_time is HH:MM in 24-hour form, in UTC. Anything else, such as 14:00:00 or 2pm, is refused with a 400 and the carrier keeps the value it had. Send null or an empty string to clear it.

Three things a carrier does to a quote:

  • Its status is a switch. paused or retired takes every method shipping with that carrier out of the quote, each with a reason, in one edit. Its old tracking links keep working.
  • Its countries narrow the method's own. A method allowed into DE and AT, shipping with a carrier that serves only DE, is not offered into AT.
  • A code with no carrier row is still accepted. A method that names carrier: "acme" with no such row quotes normally and reports acme as a plain name, with no tracking template.

To turn a stored tracking code into a link, ask the carrier instead of storing a URL per shipment:

Build a tracking link
curl -s -X POST https://api.revenexx.com/v1/shipping/tracking \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "carrier": "regio-courier", "tracking_code": "ACME000000001DE" }'

A carrier with no template answers 200 with "url": null and a reason, never a half-filled URL.

See Carriers and tracking for the rest of the carrier fields, service levels and weight units.

3. Set the market policy

Some rules are true of a shop, not of one method. They are settings, and they are set per market, with the tenant value as the fallback. They are edited in the Cockpit under Settings, Shipping. There is no public route that writes an app's settings (as of 2026-09), so this step is a Cockpit action. Read the effective values back over the API:

Read the settings
curl -s "https://api.revenexx.com/v1/settings/apps/shipping?market=de" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq .settings
SettingDefaultWhat it decides
free_shipping_thresholdnoneA shop-wide free-shipping threshold for every method that carries no free_above of its own. A method's own value always wins.
free_above_comparesgrossWhether a free-above threshold is measured on the net or the gross order value.
cutoff_time14:00Orders at or after this time of day, in UTC, ship one day later. A carrier's own cutoff_time wins over it, and the seeded carriers declare their own, so 14:00 applies only to a method with no carrier or a carrier with none. HH:MM in 24-hour form only.
handling_days1Calendar days between the order and the parcel leaving the warehouse, from 0 to 30. A carrier's own value wins.
default_carriernoneThe code of the carrier a method with no carrier ships with. A code with no carrier row is reported as a plain name.
weight_unitthe tenant's default unitThe unit the weight tiers are keyed in for this market. A request may send its own weight_unit and is converted before any tier is matched.
matrix_basis_defaultweightThe measure a matrix method with no matrix_basis prices over.
shipping_tax_classstandardThe tax class a method with none of its own falls back to. See step 5.
default_shipping_tax_ratenoneA last-resort rate, used only in the one case described in step 5.

Two of these bite in production:

  • free_above_compares defaults to gross. If your basket sends only order_value, it is read on that basis. Send order_value_net and order_value_gross and let the setting choose. Every answer echoes the basis it used in basis.free_above_compares, so a "my basket said 100" dispute is settled from the payload.
  • cutoff_time is UTC, HH:MM, and nothing else. Enter the time in UTC, not the warehouse's local time, and check the read-back. The same rule on a carrier is enforced with a 400 (step 2). A stored free_above_compares or matrix_basis_default value outside its allowed set is ignored in favour of the default. For cutoff_time and handling_days a bad stored value is not replaced by the default: read the delivery block of a quote to see what was applied.

A delivery window counts calendar days. There is no holiday or weekend calendar, so a Friday order with one handling day ships on Saturday (as of 2026-09).

4. Quote a cart

POST /v1/shipping/rates is the one call that answers a price. It writes nothing, so asking twice with the same at gives the same answer (the delivery window depends on the instant), and your checkout can ask on every change of basket. Send the destination and whatever your matrix methods measure:

Quote a cart
curl -s -X POST https://api.revenexx.com/v1/shipping/rates \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "country": "DE",
    "currency": "EUR",
    "order_value": 89.90,
    "order_value_net": 75.55,
    "order_value_gross": 89.90,
    "weight": 12,
    "weight_unit": "kg",
    "at": "2026-07-24T09:00:00Z"
  }'

The answer, shortened. The values are illustrative, and the freight entry stands for a matrix method whose table is still empty. The cut-off is 16:00 because the seeded DHL declares its own, and a carrier's cut-off wins over the market setting:

Response
{
  "rates": [
    {
      "code": "parcel-eu",
      "name": "Parcel",
      "price": 4.9,
      "currency": "EUR",
      "pricing_type": "fixed",
      "quote_required": false,
      "carrier": "dhl",
      "carrier_source": "method",
      "delivery": {
        "ship_date": "2026-07-25",
        "earliest": "2026-07-28",
        "latest": "2026-07-30",
        "handling_days": 1,
        "cutoff_time": "16:00",
        "cutoff_passed": false
      },
      "tax_class": "standard",
      "tax_rate": 19,
      "tax_source": "tenant_class"
    },
    {
      "code": "parcel-by-weight",
      "name": "Parcel by weight",
      "price": 8.9,
      "currency": "EUR",
      "pricing_type": "matrix",
      "quote_required": false,
      "tax_class": "standard",
      "tax_rate": 19,
      "tax_source": "tenant_class"
    }
  ],
  "excluded": [
    { "code": "freight", "reason": "matrix has no rate tiers configured" }
  ],
  "tax": { "resolved": true, "market_id": "…", "source": "country" },
  "basis": {
    "free_above_compares": "gross",
    "matrix_basis_default": "weight",
    "weight_unit": "kg",
    "weight_unit_factor": 1,
    "evaluated_at": "2026-07-24T09:00:00.000Z"
  }
}

How to read it:

  • rates are the methods that apply, in the merchant's position order. Print them in that order. It is not cheapest-first.
  • excluded is the diagnostic half. A method that does not apply is never an error: it lands here with a sentence saying why. An empty rates is a legitimate answer, so read excluded before you open a ticket. A quote considers at most 200 methods (as of 2026-09).
  • price is rounded to two decimals, in the currency you asked in. The App neither converts nor selects by currency: the figure a method stores is returned, labelled with the currency you asked in. Ask in the currency your methods carry. A shop selling in two currencies keeps a method per currency, names them so (say parcel-eu-gbp), and filters the rates by code itself. Every rate carries the currency you asked in, not the method's.
  • quote_required: true means price is null. The measure went above the method's quote_above, so it is offered without a computed price. That is unknown, not zero, and the checkout must not add 0.00. Show "shipping on request".
  • delivery is what the checkout prints: ship_date, earliest and latest. Pass at to compute the cut-off at a specific instant, or the server's current time is used. The example lands the ship date on a Saturday because only calendar days are counted.

A weight, quantity or attribute matrix is left out, with a reason, when the request does not carry its measure. An order_value matrix is different: it reads the bare order_value, which defaults to 0 when omitted, so a request without it is priced at the lowest tier with no warning. order_value_net and order_value_gross never feed a matrix, so always send order_value too when any order_value matrix exists. The most common cause of a missing method is a checkout that forgot to send weight. A quantity matrix reads quantity, an attribute matrix reads the attributes entry named in its matrix_attribute (for example "attributes": { "pallet_spots": 2 }). A weight_unit the tenant does not keep, an at that is not ISO 8601, a malformed market_id and an X-Revenexx-Market header that is not a market code are each a 400.

Which market the tax comes from

The tax on a rate belongs to the buyer's market. It is taken from the first of these that is present, and never guessed:

  1. The X-Revenexx-Market header, a market code such as de.
  2. The market_id field in the body, a market id.
  3. The market whose code equals the destination country code, case-insensitive: DE finds a market coded de. A market coded dach is never found this way.
  4. The tenant's only market, when it has exactly one.

The header is a hard pin. A header naming no market of this tenant does not fall through to the country. It answers tax.resolved: false. The same header also restricts which methods are candidates: the ones assigned to that market plus the unassigned ones.

5. Handle tax that is unknown

A rate carries tax_class, tax_rate and tax_source, and the answer carries a tax block. When the block says "resolved": false, every rate's tax_class, tax_rate and tax_source are null, and that means the tax is unknown. It does not mean 0 %, and it does not mean tax-free. The prices are still returned, and the call still answers 200.

An unresolved answer
{
  "rates": [ { "code": "parcel-eu", "price": 4.9, "tax_rate": null, "tax_class": null, "tax_source": null } ],
  "excluded": [],
  "tax": {
    "resolved": false,
    "reason": "market_required",
    "message": "no market in context: send 'market_id' or the x-revenexx-market header…"
  }
}

Branch on tax.resolved, then on tax.reason. Treat the list of reasons as open-ended, and keep a default branch. The published API reference does not yet list unknown_market (as of 2026-09).

reasonWhat happenedWhat the checkout does
market_requiredThe tenant has several markets and the request named none.Send the X-Revenexx-Market header or market_id, or a country that matches a market code, and ask again.
unknown_marketThe X-Revenexx-Market header names no market of this tenant.Fix the code the storefront sends.
no_marketsThe tenant has no markets at all.Set up a market in Cockpit first.
no_tax_classesThe market is known but registers no tax classes.Add a class to the market, or set default_shipping_tax_rate in the Shipping settings.
lookup_failedThe market or its classes could not be read.Ask again after a short wait.

Whatever the reason, do not add 0 % and do not print the shipping line as tax-free. Either fix the input and re-ask, or hold the order's shipping tax as pending until you have a resolved answer, or decline to quote shipping. An answer that looks tax-free gets invoiced as tax-free.

When the tax does resolve, the class comes from this chain, first match wins, and tax_source names the step that answered:

  1. The method's own tax_class (method).
  2. The shipping_tax_class setting, standard by default (tenant_class). Only consulted when the method names no class of its own.
  3. The market's default class (market_default). This is where a class name the market does not define falls through to, so a typo degrades to the market's answer and not to no tax.
  4. default_shipping_tax_rate (tenant_default), used only when the market is known and registers no classes at all. The tax block then carries "via": "tenant_default", so you can tell a stated fallback from a registered rate. It is never used while the market itself is in doubt, because charging one market's rate on a shipment into another is worse than quoting none.

Steps 1 to 3 hold a class code, never a rate. The rate lives in the market. A market's classes are cached for a few minutes, so a rate you just changed takes that long to reach a quote (as of 2026-09).

Troubleshooting

  • A filter did nothing. Methods, carriers and tiers filter by exact column value, and a query key that names no column of that entity is silently ignored. The call still answers 200 with the full, unfiltered page. GET /v1/shipping/methods?status=active is the classic trap, since a carrier has a status and a method does not. Compare the filter object in the answer with what you sent: an empty filter beside a full page means nothing was applied. Values in filter are strings, so "enabled": "true".
  • A sort or filter on service levels or weight units did nothing. Those two lists honour limit and offset and nothing else, and always come back in position order.
  • 400 on a list call. An order that is not column.asc or column.desc, or that names a column the entity does not have, is refused. Unlike a filter key, it is not silent.
  • A method you created never appears in rates. Read excluded. It is disabled (set enabled: true), the wrong country, a paused carrier, a matrix with no tiers, or a matrix whose measure your request did not send.
  • The free-shipping promise did not apply. Read free_reason on the rate when it did apply. When it did not, check which basis basis.free_above_compares says was compared, and that your request sent that figure.
  • A weight priced the wrong bracket. Check basis.weight_unit and basis.request_weight_unit. Tiers are matched in the market's unit, and your weight is converted first.
  • 401 or 403. The tenant slug or key is wrong, or the key has no access to Shipping. See API keys and Errors.
  • 409 on a create. A method or carrier code is already taken in the tenant, or two tiers share a from_value.

Where to go next

Was this page helpful?