Carriers and tracking
A carrier row is one company shipping one class of service. It owns the tracking-URL template, the service level, the transit days, the pickup cut-off and the handling days — and every method that ships with it inherits all of those unless it states its own.
A carrier selling both a parcel and an express product is two rows.
The carrier row
curl -X POST https://api.revenexx.com/v1/shipping/carriers \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{
"code": "regio_courier",
"name": "Regio Courier",
"service_level": "standard",
"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"]
}'
| Field | Meaning |
|---|---|
code | Required, unique per tenant. A method references it as carrier_id, or as a carrier text equal to this code. |
name, labels | Display. |
service_level | One of the tenant's own levels. |
tracking_url_template | The URL shape for this carrier. |
eta_days_min, eta_days_max | Transit promise. |
handling_days | Days in the warehouse before it leaves. |
cutoff_time | Pickup cut-off, HH:MM in 24-hour UTC. |
countries | Where this carrier serves. |
status | active, paused or retired. A paused or retired carrier's methods land in excluded on a rate call. |
position | Sort order. |
service_level must name one of the tenant's levels, and cutoff_time must be HH:MM in 24-hour UTC. Both are refused rather than accepted — a cut-off the estimator cannot read would be dropped in silence, and the shop would keep promising a ship date nobody computed.Creating a carrier quotes nothing on its own. A method has to reference it before any of it is inherited.
Update and remove a carrier
PUT /v1/shipping/carriers/{id} is a partial update: send only what changes. It is also a write whose effect reaches every method that ships with the carrier.
curl -X PUT https://api.revenexx.com/v1/shipping/carriers/$CARRIER_ID \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"status": "paused"}'
- Moving
statusoffactivetakes every method that ships with this carrier out ofPOST /v1/shipping/rates, each with a reason. Tracking links are not gated on it. - Editing
cutoff_timeorhandling_daysmoves the ship date of every method that ships with this carrier; a method cannot override either. Editingeta_days_min/eta_days_maxmoves the delivery window of every method that states no transit days of its own. The estimator adds the handling days, then one more day when the cut-off has already passed, compared in UTC. The API reference still says all four of these move the promised ship date; the API moves it forcutoff_timeandhandling_daysonly, as described here. codeandexternal_idmust stay unique within the tenant.
Status codes for PUT: 400 when the id is not a uuid, the body names no column, service_level is one the tenant does not keep, cutoff_time is not HH:MM, code has a capital letter (invalid_carrier_code), a transit or handling value does not hold together (invalid_eta, eta_window_inverted, invalid_handling_days), countries is not two-letter codes (invalid_countries), or the body changes a field the platform writes, such as created_at (server_owned_field). 404 when there is no carrier with that id in this tenant. 409 unique_violation when another carrier already holds the code or the external_id, and 409 service_level_inactive when the edit moves the carrier onto a level the merchant set inactive.
DELETE /v1/shipping/carriers/{id} is a hard delete, and it never answers a conflict. The answer is {"deleted": true, "id": "…"}.
- The methods that referenced the carrier keep working: their
carrier_idis cleared and they resolve through theircarriercode, or the market'sdefault_carriersetting when they have none. - What they lose is everything they inherited: the tracking template, the pickup cut-off, the handling days and the transit days. Unless that code matches another carrier, their ship date is recomputed on the market's own cut-off and handling settings, and a method with no ETA of its own stops carrying a
deliveryestimate. Nothing errors; the promise in the checkout changes. POST /v1/shipping/trackingfor the deleted carrier's code now answers404, so shipments that store only that code stop getting a tracking link.- When a carrier is merely finished,
PUTit tostatus: retiredinstead. Status codes:400when the id is not a uuid,404when there is no carrier with that id in this tenant.
The shipped catalogue
curl https://api.revenexx.com/v1/shipping/carriers/catalog \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
The DACH set — the three German parcel networks, the express carriers, the AT/CH incumbents and the pallet forwarders — each with the tracking template, service level, transit time and pickup cut-off it would be created with. seeded marks the four a fresh install already has.
Adding a carrier to the catalogue is a data change, never a code change, and a merchant may of course create one that is not in it at all.
curl -X POST https://api.revenexx.com/v1/shipping/carriers/defaults \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{}'
Creates the four networks a DACH shop is expected to have — DHL, DPD, GLS and UPS — by code, and only the ones that are missing. The app runs it on install; calling it by hand afterwards is how a tenant that predates a catalogue entry catches up.
An existing row belongs to the merchant. Only columns that are genuinely empty are filled in — a tracking template added to the catalogue after their install — never a value they set. Nothing is deleted, and calling it twice costs nothing.
Resolve a tracking link, do not store one
curl -X POST https://api.revenexx.com/v1/shipping/tracking \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"carrier":"dhl","tracking_code":"00340434161094042557"}'
Hand in a carrier code and the number printed on the label, and this answers the URL a buyer follows. The carrier owns the URL format, so nobody else has to.
order_shipments stores a tracking_url per shipment, which is one carrier's URL shape copied into every row — the day the carrier changes it, every historic link is wrong. Ask this route instead.Tracking is not gated on carrier status: a retired carrier's old shipments stay resolvable.
Service levels
A service level is the class of service a carrier row represents, as one of the tenant's own codes.
curl -X POST https://api.revenexx.com/v1/shipping/service-levels \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"code":"two_man","title":"Two-man delivery","position":40}'
It is carried by a carrier's service_level and reported on a rate as carrier_service_level. Nothing in the app branches on it, which is why a merchant can add a night courier, a two-man delivery or a same-day run.
A method never names one — it gets its level through the carrier it ships with.
code and title are required. The code is lowercase and cannot be changed afterwards, because every carrier carrying it would be orphaned. Creating one changes nothing on its own: a carrier has to be moved onto it. POST /v1/shipping/service-levels/{id}/make-default moves the default flag.
active (boolean, default true) offers the level to new carriers; false retires it, and a retired level stays on the carriers that carry it. An inactive level cannot be created as the default, and making one the default by PUT or make-default is 409 inactive_cannot_be_default. GET /v1/shipping/service-levels filters on active (?active=true or ?active=false), and it is the only filter that list honours. The default cannot be deactivated: 409 default_must_stay_active.
Each level is also addressed by its row id.
GET /v1/shipping/service-levels/{id}reads one level. A caller holding only the code readsGET /v1/shipping/vocabularies/service-levelsinstead, because the collection has no?code=filter.PUT /v1/shipping/service-levels/{id}changes the display (title, description, their locale maps, tone, position),activeand the default flag. The code stays as it is: a different one is400. Movingpositiondoes not renumber the other levels, so a deliberate order means sending every row's position.DELETE /v1/shipping/service-levels/{id}removes a level no carrier uses. The answer is{"deleted": "<code>"}: the code, not the id. It refuses with409while a carrier still carries the level (value_in_use), for the last level (last_value), and for the default while no other active level can take the flag (default_must_stay_active). When the deleted level was the default, the flag moves to the first remaining active level.
Status codes for PUT: 400 when the id is not a uuid, there is no body or no field, title is blank, active is not a boolean, or code differs from the stored one. 409 default_must_stay_active when the edit would deactivate the default, and 409 inactive_cannot_be_default when it makes an inactive level the default. 404 when there is no level with that id in this tenant.
GET /v1/shipping/vocabularies is the index of this app's published vocabularies. It gives each name, title and description, and no values; the values of one vocabulary come from its own GET /v1/shipping/vocabularies/{name}.
Weight units
curl -X POST https://api.revenexx.com/v1/shipping/weight-units \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"code":"t","title":"Tonne","factor":1000}'
For a merchant weighing goods in something the app was not shipped with — a tonne for pallet freight, a carat for jewellery — who wants a rate matrix keyed in it.
factor is required and must be greater than 0. Zero does not convert a weight, it divides by it; a negative factor turns a parcel into a credit.A new unit is never the base. Which unit anchors the others is decided at install, and it moves only through make-base (below), which rescales every factor so no weight changes meaning.POST /v1/shipping/weight-units/{id}/make-default picks the unit a matrix is quoted in.
POST /v1/shipping/weight-units/{id}/make-base is the only way the base unit moves. The unit becomes the base with factor 1, and every other unit's factor is divided by the old factor of the new base in the same call, so each unit still weighs what it did. Rate tiers are not changed, because they are kept in the unit a market quotes in, never in the base. Calling it on the base itself changes nothing. Send {}. The answer is the new base row plus rescaled, the number of other units (0 on the base itself). An unknown id gives 404, and a non-uuid id gives 400. 409 inactive_cannot_be_base refuses an inactive unit. 409 factor_unusable applies only when the target unit's own factor is not a positive number. 409 factor_out_of_range means a rescaled factor would fall outside what the store holds. Nothing is written unless every check passes.
active (boolean, default true) offers the unit for new assignments; false retires it, and a retired unit stays on the records that already use it. An inactive unit cannot be made the default either. The default unit cannot be deactivated (409 default_must_stay_active), and neither can the base unit (409 value_must_stay_active). GET /v1/shipping/weight-units filters on active (?active=true or ?active=false), and it is the only filter that list honours.
Each unit is also addressed by its row id.
GET /v1/shipping/weight-units/{id}reads one unit. A caller holding only the code readsGET /v1/shipping/vocabularies/weight-unitsinstead, because the collection has no?code=filter.PUT /v1/shipping/weight-units/{id}changes everything except the code and the base flag. The base unit's factor stays1: changing it is refused, and sending that1back is accepted. The API reference still says a factor sent for the base unit is refused; the API accepts it when it equals the stored1, as described here. Any other unit's factor can change. Retiring the base unit is409value_must_stay_active.DELETE /v1/shipping/weight-units/{id}removes a unit nothing quotes in. The answer is{"deleted": "<code>"}: the code, not the id. It refuses with409when the unit is the base or the market in the request names it inweight_unit(bothvalue_in_use), when it is the last unit (last_value), or when it is the default and no other active unit can take the flag (default_must_stay_active). The market check is best effort: another market may still name the unit, and its quotes then fall back to the flagged unit rather than failing.
Status codes for PUT: 400 when the id is not a uuid, the body is empty or names no field, title is blank, active is not a boolean, code or is_base changes, or factor is not a positive number up to 1e12 or changes the base unit's factor. 409 value_must_stay_active when it retires the base unit, default_must_stay_active when it retires the default, inactive_cannot_be_default when it makes an inactive unit the default. 404 when there is no unit with that id in this tenant.
Status codes for DELETE: 400 when the id is not a uuid, 404 when there is no unit with that id, and the 409s above.
What a method inherits, and what it overrides
| Property | Owned by | Overridable on the method |
|---|---|---|
| Price, pricing model, tiers | Method | — |
| Countries | Both | Yes — both are checked |
| Tracking template | Carrier | No |
| Service level | Carrier | No |
| Transit days | Carrier | Yes (eta_days_min / eta_days_max) |
| Pickup cut-off, handling days | Carrier | No |
| Tax class | Method, falling back to the tenant setting | Yes |
Where to go next
- Shipping rates — methods, tiers and the rate call.
- Orders — where a carrier and tracking code land on a shipment.
- Settings — the
shipping_tax_classfallback and friends.