# Migrate from v2 to v3

Move previous-generation requests to v3 without duplicating writes or losing data.

- Source URL: https://www.signcustomiser.com/help/api/guides/migration-v2-to-v3/
- Markdown URL: https://www.signcustomiser.com/help/api/guides/migration-v2-to-v3.md

## Guide

The documented v2 and unversioned public operations are deprecated. They still authenticate, validate, and return the same bodies and status codes. This change adds deprecation signals and a supported path to v3. It does not remove an operation or change its business behaviour.

The generated schedule below records the fixed deprecation instant and scheduled Sunset. The six-month migration window starts at that deprecation instant. Actual route removal still depends on observed usage, affected-consumer communication, and separate approval. Do not treat the scheduled date as permission to leave a migration until the final day.

## Read the response signals

Every affected response carries the same three headers, including authentication, validation, and not-found responses:

```http
Deprecation: @<unix-seconds>
Sunset: <http-date>
Link: <migration-guide-url>; rel="deprecation"; type="text/html"
```

`Deprecation` is an RFC 9745 structured-field date expressed as Unix seconds. `Sunset` is an RFC 8594 HTTP date. `Link` points to this guide and may appear alongside other Link values. These headers describe lifecycle state. They do not alter the response body.

The operation replacement table below is generated from the checked migration map. It covers all 13 deprecated request operations and names the Store-key scope required by each v3 replacement.

## Replace wildcard keys

Legacy wildcard tokens continue to work during migration, including on v3, but do not carry a useful least-privilege boundary. Create a scoped key for the exact Store and operations you are moving. Start with `GET /api/v3/stores/me` and confirm `data.id`, `data.platform`, and `data.scopes` before reading or writing anything.

The API and documentation now say **Store** where older payloads or code may say **Team**. This is a terminology change at the public boundary. Do not rewrite an integer identifier merely because its label changed.

Keep the old credential until the migrated integration has passed read comparisons and a controlled write cutover. Revoke it once no previous-generation caller remains.

## Update response handling

V3 success responses use an envelope:

```json
{
  "data": { "object": "customiser", "id": 42 },
  "links": {
    "self": "https://web.signcustomiser.com/api/v3/customisers/42",
    "documentation": "https://www.signcustomiser.com/help/api/"
  },
  "meta": {
    "api_version": "v3",
    "request_id": "req_01jz9x2k7c8f3m5n6p7q8r9s0t"
  }
}
```

Collections add `pagination` and `links.next`. Follow `links.next` unchanged while `pagination.has_more` is true. Cursors are opaque and tied to the filter set that produced them.

V3 errors use RFC 9457 `application/problem+json`, not the previous generation's `message` plus field-keyed `errors` shape. Branch on the HTTP status and stable `code`. Keep `request_id` in logs and support reports. Validation problems put field details in `errors[]` with JSON Pointers and may include `allowed_values`.

```json
{
  "type": "https://www.signcustomiser.com/help/api/problems/validation-failed",
  "title": "The request failed validation",
  "status": 422,
  "code": "validation_failed",
  "detail": "Correct the fields listed in errors.",
  "errors": [
    {
      "pointer": "/pricing_model",
      "code": "unsupported_value",
      "allowed_values": ["simple_letter", "advanced_letter", "frame_fit"]
    }
  ],
  "request_id": "req_01jz9x2k7c8f3m5n6p7q8r9s0t"
}
```

## Migrate pricing documents

Read the current v3 document before changing it. `GET /api/v3/customisers/{customiser_id}/pricing` returns the document under `data`. Send the document itself, without the `data`, `links`, or `meta` envelope, to a write operation.

Choose the write that matches your intent:

- `PATCH` applies an RFC 7396 JSON Merge Patch. Omitted properties stay unchanged, `null` removes a property, and arrays are replaced as a whole.
- `PUT` is a full replacement. Omitted sizes, price lists, and rows are deleted. Keep every record that must survive.
- `POST /api/v3/customisers/{customiser_id}/pricing/validate` runs the same validation without persisting, changing caches, or consuming an idempotency key. To dry-run a patch, apply it locally to the fetched document and validate the result.

Both persistent writes require `pricing:write` and an `Idempotency-Key`. Reusing the same key with the same method, path, and body replays the original result. Reusing it with a different body returns `409 idempotency_key_conflict`.

### Translate the document shape

Most structural fields remain snake_case. Apply these field changes before validation:

| Previous-generation field               | v3 field or behaviour                                                                                                        |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| request wrapper `pricing`               | send the pricing document as the whole request body                                                                          |
| response wrapper `pricing`              | read the document from `data`                                                                                                |
| `customiser_id`                         | unchanged; optional read-only guard on writes                                                                                 |
| `pricing_model`                         | unchanged field name; translate its value with the generated glossary and treat it as a read-only guard                      |
| `value_units`                           | removed; read `currency` and the field descriptions instead                                                                  |
| `sizes`                                 | unchanged collection name                                                                                                    |
| `sizes[].height_strategy`               | `sizes[].sizing_strategy`; translate every value with the generated glossary                                                 |
| `sizes[].height_measurement_mode`       | unchanged field name; translate `LINE_HEIGHT` and `LETTER_HEIGHT` to their lowercase values                                  |
| `base_pricing` and its snake_case fields | unchanged                                                                                                                    |
| `price_lists` and its snake_case fields | unchanged, except each `pricing_id` becomes `price_list_id`                                                                  |
| `sheet_pricing.pricings`                | `sheet_pricing.rows`                                                                                                         |
| `cmLimitWidth`                          | `width_limit_cm`                                                                                                             |
| `cmLimitHeight`                         | `height_limit_cm`                                                                                                            |
| `basePrice`                             | `base_price`                                                                                                                 |
| `pricePerSqCm`                          | `price_per_sq_cm`                                                                                                            |
| no equivalent                          | `object`, `pricing_model_display_name`, and `currency` are new read-only fields; omit them or echo the fetched values on writes |
| implied currency and units              | integer amount fields use minor units; per-centimetre rates use decimal major units                                          |

The generated glossary below owns every pricing and sizing enum translation. The important distinctions are:

- `FIXED_WIDTH` becomes `simple_letter`.
- `MATERIAL_LENGTH` becomes `advanced_letter`.
- `FRAME_FIT` becomes `frame_fit`.
- The retired `FIXED_HEIGHT` pricing model becomes read-only `legacy_fixed_height` migration metadata. V3 rejects it on writes.
- The supported `fixed_height` sizing strategy is different. Its older spellings are `HEIGHT` and `FIXED`.
- The supported `fixed_width` sizing strategy replaces `WIDTH` and `DYNAMIC`.
- `LINE_HEIGHT` becomes `line_height`; `LETTER_HEIGHT` becomes `letter_height`.

### Pricing request diff

Previous-generation `PUT` wraps the document and mixes old enum values with camelCase sheet bands:

```json
{
  "pricing": {
    "pricing_model": "FIXED_WIDTH",
    "sizes": [
      {
        "size_id": 7,
        "width_cm": 50,
        "height_strategy": "WIDTH",
        "height_measurement_mode": "LINE_HEIGHT"
      }
    ],
    "sheet_pricing": {
      "pricings": [
        {
          "cmLimitWidth": 100,
          "cmLimitHeight": 60,
          "basePrice": 2000,
          "pricePerSqCm": 1.5
        }
      ]
    }
  }
}
```

V3 `PUT` takes the canonical document directly:

```json
{
  "pricing_model": "simple_letter",
  "sizes": [
    {
      "size_id": 7,
      "width_cm": 50,
      "sizing_strategy": "fixed_width",
      "height_measurement_mode": "line_height"
    }
  ],
  "sheet_pricing": {
    "rows": [
      {
        "width_limit_cm": 100,
        "height_limit_cm": 60,
        "base_price": 2000,
        "price_per_sq_cm": 1.5
      }
    ]
  }
}
```

The v3 response puts the saved document in `data`, then adds `links` and `meta`. Do not send those envelope fields back on a write.

## Move webhook management

Webhook CRUD moves to `/api/v3/webhook-subscriptions`. The old `PUT` update becomes `PATCH` and accepts a merge patch for `topic` and `url`. Create, update, and delete require `webhooks:write` plus an `Idempotency-Key`. Reads require `webhooks:read`.

Replace `GET /api/v2/webhook-examples/{topic}` with `GET /api/v3/webhook-event-types`. The v3 catalogue returns every available topic and a static example in one response. Clients must tolerate new topic values. Subscription creation returns its signing secret once; later reads do not expose it.

The delivery payload and signature-verification contract are separate from subscription CRUD. Keep verifying the raw request body before decoding JSON.

## Move integration writes

Both legacy order-create routes move to `POST /api/v3/orders`. The unversioned product route moves to `POST /api/v3/customisers/{customiser_id}/products`. These operations require enabled custom integrations and Store-scoped keys.

### Translate the v2 custom-integration order

| v2 order field | v3 order field or behaviour |
| --- | --- |
| `integration_id` | unchanged |
| `external_id` | `store_order_id` |
| `external_order_number` | `store_order_number` |
| no equivalent | `total_amount` is now required in integer minor currency units |
| `currency` | unchanged; send an uppercase ISO 4217 code |
| `shipping_line` | `shipping_method` |
| `customer.*` | unchanged field names |
| `billing_address.*`, `shipping_address.*` | unchanged snake_case names; v3 also accepts nullable `company`, `email`, and `phone` fields |
| `products[].external_id` | `line_items[].product_id`; use the integer `data.id` returned by the v3 product write |
| `products[].quantity` | `line_items[].quantity` |
| `products[].price` | removed from the order request; v3 snapshots the price from the referenced product |

The v2 response uses an `order` wrapper and repeats the submitted external product fields. V3 returns `201`, puts the canonical order under `data`, projects stored products under `data.line_items`, and adds `links` and `meta.request_id`.

### Translate the unversioned order

| Unversioned field | v3 order field or behaviour |
| --- | --- |
| `order_id` | `store_order_id` |
| `order_number` | `store_order_number` |
| `order_total` | `total_amount`; convert decimal major units to integer minor units before sending |
| `order_currency` | `currency` |
| `email` | `customer.email` |
| `shipping_line` | `shipping_method` |
| `shipping_address.name` | split into `shipping_address.first_name` and `shipping_address.last_name` |
| `shipping_address.address1`, `address2`, `zip` | `shipping_address.address_1`, `address_2`, `postcode` |
| other shipping address fields | unchanged names |
| `products[].id` | create each product through v3 first, then use its integer `data.id` as `line_items[].product_id` |
| legacy product artwork fields | move them to the v3 product write, not the order request |

The unversioned response is only `{ "ok": true }`. V3 returns the stored order resource, line-item projection, self link, and request ID.

### Translate the unversioned product

| Unversioned field | v3 product field or behaviour |
| --- | --- |
| path `{customiser}` | path `{customiser_id}` |
| no equivalent | `integration_id`, `title`, `price_amount`, and `currency` are now required |
| `product_id` | `store_product_id` |
| `cart` | transform the selections you need into `customisations[]` records with stable snake_case `key`, `label`, and `value` fields |
| `price_breakdown` | no direct field; calculate `price_amount` in minor units and keep any integration-specific breakdown outside the API resource |
| `custom_background_path` | `artwork.custom_background_url` |
| `custom_background_original_path` | `artwork.custom_background_original_url` |
| `logo_original_path` | `artwork.source_file_url` |
| `product_type_paths` | no generic map; send supported URLs through the matching explicit `artwork` field, such as `preview_image_url`, `outline_image_url`, `svg_url`, `eps_url`, `illustrator_pdf_url`, or `dxf_url` |

The unversioned response is only `{ "ok": true }`. The v3 `201` response returns the product under `data`, including its integer ID for later order line items, plus `links` and `meta.request_id`.

The product write now names the integration and external record explicitly. Money uses integer minor units, dimensions use `_cm`, and artwork fields sit under `artwork`:

```json
{
  "integration_id": 17,
  "store_product_id": "ext-product-1001",
  "title": "Custom neon sign",
  "price_amount": 12900,
  "currency": "AUD",
  "width_cm": 80.5,
  "height_cm": 31.5,
  "artwork": {
    "preview_image_url": "https://cdn.example.com/sign.png",
    "svg_url": "https://cdn.example.com/sign.svg"
  }
}
```

A successful response returns the modern product ID under `data.id`. Use that ID as `line_items[].product_id` when creating the v3 order:

```json
{
  "integration_id": 17,
  "store_order_id": "ext-order-1001",
  "store_order_number": "#1001",
  "total_amount": 25800,
  "currency": "AUD",
  "line_items": [{ "product_id": 731, "quantity": 2 }]
}
```

The order response returns `data.object: "order"`, `data.status: "received"`, the stored line-item projection, a `links.self` URL, and `meta.request_id`. `received` means Sign Customiser accepted the order. It does not claim payment, manufacture, fulfilment, shipping, refund, or cancellation.

Both integration writes require a unique `Idempotency-Key` for each logical record. A replay with the same key and body returns the original `201` and `Idempotency-Replay: true`. Never send one logical product or order to the previous-generation and v3 write routes in parallel.

## Cut over without duplicate writes

1. Inventory every previous-generation method and path in your integration. Match the result against the generated table below.
2. Create a Store-scoped key with the listed read and write scopes. Confirm the Store with `/api/v3/stores/me`.
3. Move reads first. Compare old and v3 data in a non-production environment while teaching the client about envelopes, pagination, problems, and canonical values.
4. Transform a fetched pricing document, then use the validate endpoint. Test `PATCH` or `PUT` against controlled data only after validation passes.
5. Add durable idempotency keys to every v3 write. Store each key with the logical product, order, webhook change, or pricing change.
6. Stop the old writer before enabling its v3 replacement. Do not dual-write. If the cutover result is uncertain, retry the v3 request with the same key and byte-equivalent body.
7. Monitor v3 responses and `request_id` values. Keep the old path disabled rather than using it as an automatic write fallback.
8. Remove old reads, revoke the wildcard key, and confirm no previous-generation request remains before the scheduled Sunset.

The tables below are generated from the checked operation map and canonical glossary. Use them as the source for route, scope, pricing, and sizing translations.

## Deprecation schedule

Deprecation took effect on 22 July 2026. Sunset is scheduled for 22 January 2027.

[Migration guide](https://www.signcustomiser.com/help/api/guides/migration-v2-to-v3/)

## Operation replacements

| Previous operation | v3 replacement | Required scope |
| --- | --- | --- |
| `POST /api/v2/orders` | `POST /api/v3/orders` | `orders:write` |
| `GET /api/v2/customisers` | `GET /api/v3/customisers` | `customisers:read` |
| `GET /api/v2/customisers/{customiser}` | `GET /api/v3/customisers/{customiser_id}` | `customisers:read` |
| `POST /api/orders` | `POST /api/v3/orders` | `orders:write` |
| `POST /api/customisers/{customiser}/products` | `POST /api/v3/customisers/{customiser_id}/products` | `products:write` |
| `GET /api/v2/customisers/{customiser}/pricing` | `GET /api/v3/customisers/{customiser_id}/pricing` | `pricing:read` |
| `PUT /api/v2/customisers/{customiser}/pricing` | `PUT /api/v3/customisers/{customiser_id}/pricing` | `pricing:write` |
| `GET /api/v2/webhooks` | `GET /api/v3/webhook-subscriptions` | `webhooks:read` |
| `POST /api/v2/webhooks` | `POST /api/v3/webhook-subscriptions` | `webhooks:write` |
| `GET /api/v2/webhooks/{webhook_id}` | `GET /api/v3/webhook-subscriptions/{webhook_id}` | `webhooks:read` |
| `PUT /api/v2/webhooks/{webhook_id}` | `PATCH /api/v3/webhook-subscriptions/{webhook_id}` | `webhooks:write` |
| `DELETE /api/v2/webhooks/{webhook_id}` | `DELETE /api/v3/webhook-subscriptions/{webhook_id}` | `webhooks:write` |
| `GET /api/v2/webhook-examples/{topic}` | `GET /api/v3/webhook-event-types` | `webhooks:read` |

## Finish kind

Field: `finish_kind`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `colour` | `COLOUR` | Colour |  |
| `texture` | `TEXTURE` | Texture |  |

## Height measurement mode

Field: `height_measurement_mode`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `line_height` | `LINE_HEIGHT` | Line height |  |
| `letter_height` | `LETTER_HEIGHT` | Letter height |  |

## Pricing model

Field: `pricing_model`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `simple_letter` | `FIXED_WIDTH` | Simple Letter |  |
| `advanced_letter` | `MATERIAL_LENGTH` | Advanced Letter |  |
| `frame_fit` | `FRAME_FIT` | Frame Fit |  |
| `legacy_fixed_height` | `FIXED_HEIGHT` | Fixed Height (legacy) | Read-only legacy value. Read-only migration metadata. It is not accepted on writes. |

## Product family

Field: `product_family`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `neon` | `NEON` | Neon |  |
| `channel_letter` | `CHANNEL` | Channel Letter |  |
| `metal_cut_out` | `CUTOUT_METAL` | Metal Cut-Out |  |

## Selection mode

Field: `selection_mode`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `single_style` | `SINGLE` | Single |  |
| `per_word` | `MULTI` | Multiple |  |

## Sign category

Field: `sign_category`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `neon` | `NEON` | Neon |  |
| `acrylic_channel_letter` | `ACRYLIC` | Channel Letter |  |
| `metal_channel_letter` | `METAL` | Metal Channel Letter |  |
| `metal_cut_out` | `CUTOUT_METAL` | Metal Cut-Out |  |
| `stencil` | `STENCIL` | Stencil |  |
| `lightbox` | `LIGHTBOX` | Lightbox |  |
| `wood_letter` | `WOOD` | Wood Letter |  |
| `aluminium_plate` | `ALUMINIUM` | Aluminium Plate |  |
| `braille_sign` | `BRAILLE` | Braille Sign |  |
| `wood_frame` | `WOOD_FRAME` | Wood Frame |  |

## Sizing strategy

Field: `sizing_strategy`

| Canonical API value | Legacy spelling | Current UI label | Notes |
|---|---|---|---|
| `fixed_width` | `WIDTH`, `DYNAMIC` | Fixed Width - Height scales dynamically |  |
| `fixed_height` | `HEIGHT`, `FIXED` | Fixed Height - Width scales dynamically | Supported sizing strategy; this is not the retired FIXED_HEIGHT pricing model. |
