# Shipping profiles: scope, updates and recovery

Use this guide with the [OpenAPI contract](https://developerdocs.venon.io/openapi.json). Examples use fictional IDs; resolve IDs in the authenticated shop before writing.

## Choose the right operation

| Intent                                                              | REST                                              | MCP                         |
| ------------------------------------------------------------------- | ------------------------------------------------- | --------------------------- |
| Resolve a product/variant by SKU or name                            | GET /v1/products/catalog                          | lookup_products             |
| Inspect every existing scope                                        | GET /v1/cost/shipping-profiles                    | list_shipping_profiles      |
| Read scope, current costs and history                               | GET /v1/cost/shipping-profiles/{id}               | get_shipping_profile        |
| Discover observed shipping-method titles                            | GET /v1/cost/shipping-methods                     | list_shipping_methods       |
| Create a profile or update its identity/scope/scalar costs          | POST /v1/cost/shipping-profiles                   | upsert_shipping_profile     |
| Replace all tiers and costs on one axis                             | POST /v1/cost/shipping-profiles/tiers             | set_shipping_tiers          |
| Change up to 50 existing tier costs from a date, preserving history | POST /v1/cost/shipping-profiles/{id}/cost-updates | update_shipping_costs       |
| Replace one scalar fee's complete timeline                          | POST /v1/cost/shipping-profiles/fee-history       | set_shipping_fee_history    |
| Replace one existing bucket's complete cost timeline                | POST /v1/cost/shipping-profiles/bucket-history    | set_shipping_bucket_history |

Cost reads require cost:read and writes require cost:write. Catalog lookup requires analytics:read. A workflow that reads before/after writing needs both cost scopes. The shipping-profile list returns all profiles in one array, without fees or pagination; it accepts no query parameters. Catalog lookup is paginated (page/per_page, at most 100 products per page); follow its pagination metadata and inspect all matches. A SKU can identify multiple variants.

Product status (including ACTIVE/UNLISTED) is not exposed or filterable here. Do not infer status from catalog presence, sales, or soft-deletion filtering. If a task requires status-based selection, obtain an explicit product/variant selection from the user or another authorized source.

## Scope and identity

Shipping-profile scope uniqueness is independent of the profile name. Two non-default profiles conflict when their countries, shipping methods AND products/variants all overlap. An empty array matches every value on that dimension. Any intersecting combination is enough; the complete lists do not need to be equal. A product includes all its variants, so a product profile and one of its variant profiles conflict when country and method scopes also overlap. Distinct variants of the same product can coexist. The catch-all default is excluded from this overlap check; other profiles must narrow at least one dimension.

Provide the existing profile id to update; omit it to create. The server never finds an update target by name or matching scope. Updates exclude their own id from conflict detection but still check every other non-default profile in the shop. Names must be unique within the shop and there can be only one default; is_default cannot be changed on an existing profile. Upsert requires the complete identity/scope fields. Omit fees to preserve all costs and history. Provided scalar fees overwrite current values while preserving history; a provided per_order or per_order_return axis REPLACES that axis and resets its history, including for flat axes.

Countries are ISO-2 codes. shipping_methods: [] covers all current and future shipping methods. For method-specific prices, discover observed titles first and copy them byte-for-byte; invented, whitespace-changed or case-changed titles are rejected with 422. Existing saved method titles remain accepted when updating that profile.

Each row below is an independent scenario. Assume an existing non-default profile for DE, all methods and product 101, which owns variants 1001 and 1002:

| Proposed scope                            | Result                                                   |
| ----------------------------------------- | -------------------------------------------------------- |
| Same scope, different name                | 422 scope conflict                                       |
| DE, one observed method, product 101      | 422: all-method scope overlaps the specific method       |
| DE, all methods, variant 1001             | 422: product 101 includes variant 1001                   |
| Countries DE and AT, products 101 and 102 | 422: the DE/product-101 combination intersects           |
| AT, all methods, product 101              | Allowed if no other profile conflicts and name is unique |
| Catch-all default with empty arrays       | Allowed if no default already exists and name is unique  |
| Same profile id with a new scalar fee     | Allowed; own id is excluded from overlap checks          |

Two profiles scoped separately to variants 1001 and 1002 can coexist when no product-wide profile overlaps. There is no more-specific-profile override between non-default shipping profiles.

## Create and update examples

POST /v1/cost/shipping-profiles creates a profile and fees atomically:

```json
{
  "profile": {
    "name": "Germany product 101",
    "is_default": false,
    "country_codes": ["DE"],
    "shipping_methods": [],
    "products": [
      {
        "kind": "product",
        "id": 101
      }
    ],
    "currency": "EUR",
    "fees": {
      "packaging_fee": 0.5,
      "per_order": {
        "type": "flat",
        "value": 5.9
      }
    }
  }
}
```

Success is HTTP 200 with this REST envelope (the allocated id will differ):

```json
{
  "success": true,
  "data": {
    "id": 41
  },
  "metadata": {
    "request_id": "req_example",
    "timestamp": "2026-09-14T00:00:00.000Z"
  }
}
```

To update that exact profile's packaging fee while preserving its shipping axis and history, send the complete scope plus only the scalar fee being changed:

```json
{
  "profile": {
    "name": "Germany product 101",
    "is_default": false,
    "country_codes": ["DE"],
    "shipping_methods": [],
    "products": [
      {
        "kind": "product",
        "id": 101
      }
    ],
    "currency": "EUR",
    "fees": {
      "packaging_fee": 0.75
    },
    "id": "41"
  }
}
```

Omit fees entirely for a scope/name-only update. On create, omitted fee chains start at zero. Upsert returns only a numeric id; GET returns the id as a string, camelCase fields and a current object containing fee values and history. Request parameters are snake_case; deprecated camelCase aliases are accepted by REST, but conflicting aliases are rejected.

MCP upsert takes the profile fields directly, without the REST profile wrapper. Successful MCP structuredContent wraps the returned value as {data: ...}; the text result keeps its existing value shape. Read tools use a numeric id, so convert the string id from a profile response when needed.

## Conflict responses and recovery

Creating the same scope under another unique name produces HTTP 422:

```json
{
  "success": false,
  "error": {
    "code": "SHIPPING_PROFILE_SCOPE_CONFLICT",
    "message": "This profile's scope overlaps with \"Germany product 101\" (id 41). Each combination of country, shipping method, and product can belong to only one profile",
    "conflicting_profiles": [
      {
        "id": 41,
        "name": "Germany product 101"
      }
    ],
    "recovery": "Read each conflicting profile by id and inspect its complete scope and costs. Update by id only when it is the intended target; a profile may cover additional countries or products. Otherwise narrow the proposed scope or resolve the ambiguity with the user. Changing the name or retrying unchanged cannot fix a scope conflict. Do not delete or overwrite another profile merely because it conflicts. Read back the final profile after an authorized update."
  },
  "metadata": {
    "request_id": "req_example",
    "timestamp": "2026-09-14T00:00:00.000Z"
  }
}
```

Read each conflicting profile by id and inspect its complete scope and costs. Update by id only when it is the intended target; a profile may cover additional countries or products. Otherwise narrow the proposed scope or resolve the ambiguity with the user. Changing the name or retrying unchanged cannot fix a scope conflict. Do not delete or overwrite another profile merely because it conflicts. Read back the final profile after an authorized update.

Scope conflicts use SHIPPING_PROFILE_SCOPE_CONFLICT (422). Name/default uniqueness uses SHIPPING_PROFILE_IDENTITY_CONFLICT (409); list profiles to locate the existing identity. Other semantic validation errors, including unknown method titles, use VALIDATION_ERROR (422); malformed requests use VALIDATION_ERROR (400). Missing/cross-shop ids use NOT_FOUND (404). Permission and rate-limit errors retain FORBIDDEN (403) and RATE_LIMITED (429). Match error codes rather than parsing messages. MCP business failures use isError: true with the same domain error object in structuredContent.error and readable JSON text; they are tool failures, not HTTP 422 responses from the MCP transport.

The upsert's identity, product scope and nested fee writes share one transaction. A conflict rolls back the whole upsert. Profile-scope writes in one shop are serialized, so concurrent overlapping creates cannot both commit through this API. A preflight read or dry run does not reserve a scope; handle a later conflict normally. There is no upsert-by-scope, idempotency-key support or revision precondition on this operation. Following a timeout with an uncertain outcome, re-read profiles before repeating a create. Read/modify/write workflows are not one transaction, and concurrent upserts do not provide optimistic lost-update detection.

## Tiers and history

One profile can receive its complete nested fee setup in one upsert. To replace one existing axis's quantity tiers, POST /v1/cost/shipping-profiles/tiers (MCP set_shipping_tiers):

```json
{
  "profile_id": 41,
  "axis": "per_order",
  "type": "item",
  "tiers": [
    {
      "up_to": 1,
      "cost": 5.9
    },
    {
      "up_to": 3,
      "cost": 7.9
    },
    {
      "up_to": null,
      "cost": 9.9
    }
  ],
  "dry_run": true
}
```

Use ascending upper bounds and a final null bound. This plans all tiers in one call; dry_run: false applies them. This operation REPLACES the axis and resets its history. A dry run reads and validates a plan without writing; it is not a reservation or guarantee that a later write succeeds. The nested upsert axis uses buckets/max_value/weight_display_unit; the tiers utility uses tiers/up_to/weight_unit. Use each operation's schema.

For historical values, use fee-history or bucket-history with the complete intended timeline, preview first, and retain every period that should survive. Those operations replace the selected chain; other chains are preserved. periods[].until is the exclusive END of that period; the last period has until: null (active). Returned history[].effectiveFrom is a legacy name for the same end boundary, when the next value takes over. Do not treat it as the historical row's start date.

## Dated batch cost updates

Update up to 50 existing shipping buckets in one atomic request, in the profile currency. First preview with dry_run=true, then apply with its revision as expected_revision. effective_from is an inclusive shop-local YYYY-MM-DD date; backdated and future changes are supported. Each new cost lasts until the next existing period boundary (effective_until in the result), or indefinitely if null. Earlier history, later scheduled costs, bucket boundaries and omitted buckets are preserved. At an existing start boundary only that period is updated. Equal costs are no-ops. A missing/cross-profile bucket fails the whole request with 404 and a field-level error. A changed profile returns SHIPPING_PROFILE_REVISION_CONFLICT (409); preview again before applying. Preview does not reserve the profile. There are no idempotency keys or saved responses. After an uncertain timeout, fetch the current profile and reconcile the requested costs. Repeating a committed change with its original revision returns 409; unchanged no-op requests may succeed again. Do not automatically refresh the revision and overwrite newer edits. Preview and review any remaining change before applying. The revision covers identity, scope, tiers and all fee histories. Use /tiers only to replace tier structure and /bucket-history only to explicitly replace a complete timeline.

Preview POST /v1/cost/shipping-profiles/41/cost-updates:

```json
{
  "effective_from": "2026-09-17",
  "bucket_costs": [
    {
      "bucket_id": 101,
      "cost": 5.9
    },
    {
      "bucket_id": 102,
      "cost": 7.9
    }
  ],
  "dry_run": true
}
```

To apply, send the same effective_from and bucket_costs with dry_run: false and expected_revision copied from the preview. For MCP, call update_shipping_costs with profile_id: 41 and the same body fields. After an uncertain timeout, fetch the profile and compare its costs and history with the intended change. A retry using an outdated revision returns 409; only apply again after reviewing a fresh preview.

For example, if a bucket costs 5 until October 1 and 7 thereafter, setting 6 from September 17 preserves 5 before September 17, uses 6 through September 30, and retains 7 from October 1. Backdating deliberately changes costs for historical orders during the affected interval; preview shows that interval. Changing tier boundaries still requires the separate destructive replacement operation.

With 30 profiles of 50 tiers, this requires 30 preview calls and 30 apply calls, rather than 1,500 individual bucket writes. Each profile commits atomically; the 30-profile workflow is not a single transaction.

## Quotation update workflow

1. Resolve the exact products/variants and countries covered by the quotation. Resolve ambiguous matches before writing.
2. List existing profiles, then get every intended target's complete scope, costs and history. A profile can cover more products/countries than the quotation; updating its fees affects its whole scope.
3. Use dated batch cost updates for prices on existing tiers; otherwise choose scope-only, scalar-current-value, axis-replacement or full-history operations based on the intended change. The API has no quotation ownership marker or bulk overwrite-all-quotation-profiles operation.
4. Preview supported tier/history operations and apply only the intended changes. Upsert itself has no dry_run parameter. A multi-profile workflow is not atomic; track completed profile ids and failures so resuming does not blindly repeat writes.
5. Handle conflicts using the returned profiles and recovery guidance. Do not broaden scope or destroy existing history to force a write through.
6. GET each changed profile and compare scope, current costs and preserved/replaced history with the intended result.
