> ## Documentation Index
> Fetch the complete documentation index at: https://docs.khaime.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Custom Fields

> Define per-product custom fields, collect answers at checkout, and read them back on the order

# Product Custom Fields

`additional_information` lets a business attach custom fields to an individual product — a gift note, a size dropdown, a custom-length slider — and collect the customer's answers during checkout.

The full lifecycle spans four calls:

1. **Define** the fields on the product — business side (`create-product` / `update-product`)
2. **Discover** them from the storefront (`GET /product/{id}` or `GET /products`)
3. **Submit** the customer's answers on the cart line (`/product-payment/intent`)
4. **Read** the answers back on the order (`/order/{id}`)

Steps 2–4 are the checkout integration. Step 1 is how a merchant configures the
product in the first place.

<Warning>
  **Availability:** checkout-time validation and storage of these answers ship with
  `techchak-backend` branch `feat/b2b-settings-exports-kyc-bypass`, which is not yet
  merged. The database columns are already live, but nothing populates them until
  that branch is released. Remove this callout once it ships.
</Warning>

<Note>
  **This is not the same as `checkout_form_response`.** That is a business-wide form shared across every product. `additional_information` is defined directly on one product, and each product has its own field list.
</Note>

## 1. Define fields on the product

`POST /business/create-product` and `PATCH /business/update-product/{product_id}` accept `additional_information` as an array of field definitions:

```json theme={null}
"additional_information": [
  { "id": "gift_note", "label": "Gift Note", "type": "text", "required": false },
  { "id": "size", "label": "Size", "type": "select", "required": true, "options": ["S", "M", "L"] },
  { "id": "length", "label": "Custom Length", "type": "range", "required": false, "min": 1, "max": 10, "step": 1 }
]
```

<ResponseField name="id" type="string" required>
  Stable key for the field. This is what checkout answers are keyed by, so it must be unique within the product and should not change once customers have submitted against it.
</ResponseField>

<ResponseField name="label" type="string" required>
  Display text shown to the customer. Safe to rename at any time — it is never used as a key.
</ResponseField>

<ResponseField name="type" type="string" required>
  One of `text`, `number`, `date`, `email`, `phone`, `textarea`, `select`, `checkbox`, `range`.
</ResponseField>

<ResponseField name="required" type="boolean" default="false">
  When `true`, checkout rejects a submission that omits this field or sends it blank.
</ResponseField>

<ResponseField name="options" type="string[]">
  Allowed values. **Accepted only when `type` is `select`** — sending it with any other type is rejected.
</ResponseField>

<ResponseField name="min" type="number">
  Lower bound. **Accepted only when `type` is `range`.**
</ResponseField>

<ResponseField name="max" type="number">
  Upper bound. **Accepted only when `type` is `range`.**
</ResponseField>

<ResponseField name="step" type="number">
  Step increment for the input control. **Accepted only when `type` is `range`.** Not enforced at checkout — only `min` and `max` are.
</ResponseField>

<Note>
  `options` is optional at save time, so a merchant can pick `select` before filling in the choices. A `select` field saved with no `options` accepts **any** string at checkout, because there is nothing to validate against.
</Note>

## 2. Discover the fields from the storefront

Before rendering a checkout form you need to know what a product asks for. The
public product endpoints return the definitions on the product itself:

```
GET /product/{id}      # single product
GET /products          # list
```

```json theme={null}
"additional_information": [
  { "id": "gift_note", "label": "Gift Note", "type": "text", "required": false },
  { "id": "size", "label": "Size", "type": "select", "required": true, "options": ["S", "M", "L"] },
  { "id": "length", "label": "Custom Length", "type": "range", "required": false, "min": 1, "max": 10, "step": 1 }
]
```

Build one input per entry: `type` picks the control, `label` is the caption,
`required` drives client-side validation, and `options` or `min`/`max`/`step`
populate `select` and `range` inputs. Keep each field's `id` — it is the key you
submit the answer under in the next step.

<Note>
  If the business has attached a shared custom form to the product, its fields are
  **merged into this same array**, already converted to the same shape, and
  `attached_custom_form` on the product names the form. There is no second call and no
  second shape to handle.

  One caveat from that conversion: shared-form fields of type `time` arrive as `text`,
  because there is no product-level `time` type. The label and required flag survive;
  the time-picker semantics do not.
</Note>

An empty array means the product has no custom fields — skip the form entirely, since
any answers sent for it are discarded.

For a **multi-item cart**, do this per line item: each product has its own field list,
and answers are submitted per cart line.

## 3. Submit answers at checkout

Answers go on the cart line, as an object keyed by each field's `id`:

```json theme={null}
POST /product-payment/intent
{
  "cart": [
    {
      "product_id": 501,
      "quantity": 1,
      "additional_information": {
        "gift_note": "Happy Birthday!",
        "size": "M",
        "length": 5
      }
    }
  ]
}
```

<Warning>
  **Answers are keyed by `id`, not `label`, and a wrong key fails silently.**

  Sending `{ "Size": "M" }` for a field whose id is `size` does not error — the key is simply dropped and no answer is stored. If answers are missing from an order, check the key spelling before assuming a server fault.
</Warning>

<Warning>
  **Only the `cart` array path collects custom fields.**

  A single-product purchase that does not wrap the item in a `cart` array has no code path that reads `additional_information` at all — the request succeeds and the customer's answers are discarded with no error. A "Buy Now" flow that needs custom fields must send a one-item `cart` array.
</Warning>

For physical products, `POST /cart/validate` still has to run first to obtain `cart_unique_id`. `additional_information` is ignored at that step — send it only on the final `/product-payment/intent` call.

### What gets stored

The stored answer object is rebuilt from the product's own field definitions rather than copied from the request, which means:

* Keys that do not match a defined field `id` are **dropped**
* Optional fields left blank are **omitted** rather than stored as empty
* If the product defines no fields at all, nothing is stored regardless of what was submitted

## 4. Validation errors

All four use the standard error envelope, so no separate error-handling branch is needed — only the message copy differs.

| Condition                                    | Status | `error_code`               | `message`                                                    |
| -------------------------------------------- | ------ | -------------------------- | ------------------------------------------------------------ |
| Required field missing or blank              | `400`  | `VALIDATION_MISSING_FIELD` | `"<label>" is required for <product title>`                  |
| `select` value not in that field's `options` | `400`  | `VALIDATION_FAILED`        | `"<label>" must be one of: <comma-separated options>`        |
| `range` value is not a number                | `400`  | `VALIDATION_FAILED`        | `"<label>" must be a number`                                 |
| `range` value outside `min`/`max`            | `400`  | `VALIDATION_FAILED`        | `"<label>" must be at least <min>` / `must be at most <max>` |

An unrecognized key is **not** an error — it succeeds and the key is absent from what is stored.

## 5. Read answers back on the order

`GET /order/{id}` returns the answers directly on the order object. No second lookup is needed.

```json theme={null}
"additional_information": [
  {
    "product_id": 501,
    "values": { "gift_note": "Happy Birthday!", "size": "M", "length": 5 }
  }
]
```

It is an array because one order can cover several products, each with its own field set. Match on `product_id` to find the entry for a given line item.

## Legacy shape

Before this schema, `additional_information` on a product looked like this:

```json theme={null}
{
  "id": "...",
  "information_type": "select",
  "information_lists": [
    { "label": "Size", "required": true, "dropdown_items": ["S", "M", "L"] }
  ]
}
```

Nothing backfills it. A product that already had custom fields keeps the legacy shape in `GET` responses until it is re-saved through the update endpoint, so **both shapes appear on the same API surface** until every such product has been saved once.

Rendering code that assumes the flat `{ id, label, type, required }` shape universally will break on any product that has not been touched since this shipped. Detect the shape before rendering — the presence of `information_type` or `information_lists` identifies a legacy record.

Note also that the legacy `information_type` enum is not the same set: it included `tel`, `time` and `uuid`, and lacked `number` and `checkbox`. `tel` corresponds to the new `phone`.
