# Discounts

A discount is a record in your company: a name, what it takes off, and the
products, customers and days it applies to. It takes effect only on a document
line or a document that references its id. Invoices, quotes, order
confirmations and credit notes reference discounts in `discountIds`; how a
document applies them is in
[Billing documents](/guides/billing-documents/overview#discounts).

Discounts live under the base URL
[`https://api.ledgerbee.com/api/v1`](https://api.ledgerbee.com/api/v1) and use
the product scopes:

- `products-read` — list and get discounts, and ask where they apply.
- `products-write` — create, update and delete discounts, their amounts,
  their assignments and their codes.
- `discounts-check` — look up one discount code and check a cart, and nothing
  else. Give this scope to a webshop's key so its checkout can check the code a
  shopper types without being able to list your discounts.

Authenticate with the `x-api-key` header. Ids in paths and bodies are UUIDs
and match in any letter case; responses return them upper-case.

## What a discount takes off

Each discount has a `type`, fixed when it is created, and a `scope`.

| `type` | What it takes off | Figures it carries |
| --- | --- | --- |
| `percentage` | `percentagePct` percent, above 0 and at most 100 | `percentagePct`; no amounts |
| `fixed` | An amount, per eligible line or once per document (see `scope`) | One amount per currency |
| `per_unit` | An amount off each unit on the line; `line` scope only | One amount per currency |

| `scope` | Where it applies |
| --- | --- |
| `line` | To each eligible line on its own. |
| `document` | Once per document, after every line discount, when the customer matches and at least one line's product is eligible. |

A `fixed` or `per_unit` discount states a positive amount in each currency it
supports, in major units (`"50.00"`). Amounts are never converted: a document
in a currency the discount has no amount for is refused with
`DISCOUNT_NO_AMOUNT_IN_CURRENCY`. Add a currency with
`PUT /v1/discounts/{discountId}/amounts/{currency}` and remove one with
`DELETE` on the same path.

The `name` is printed as the reason on the e-invoice allowance, so give it a
name the customer can read. `priority` orders discounts the server picks
itself (lowest first), and `combinable: false` stops a line from referencing
the discount together with another one. `maxRedemptions` and `maxPerCustomer`
limit how often it can be used; null means no limit. On `PATCH`, these two
limits and `durationInPeriods` are the only discount fields that accept null. `requiresCode: true`
makes the discount apply only through one of its [codes](#codes): a document
or an applicability check that names it by id alone is refused with the reason
`codeRequired`, and `GET /v1/discounts/applicable` leaves it out.
`durationInPeriods` limits how many billing periods the discount applies to on
a subscription; null means every period (see
[Codes on a subscription](#codes-on-a-subscription)).

## Redemption limits

A use is counted when an invoice that references the discount is booked: one
use per invoice, however many of its lines the discount reduces. Saving or
editing a draft and sending a quote use nothing, and invoices imported from
another system are never counted. A booking that would go past a limit is
refused and the invoice stays a draft. When a credit note leaves nothing
creditable on an invoice, the invoice's uses are given back; a partial credit
note keeps them.

A use claimed through a [code](#codes-on-an-invoice) also counts against the
code's own `maxRedemptions`. The limits are judged in this order, and the first
one used up is the one named: the discount's `maxRedemptions`, the customer's
`maxPerCustomer`, then the code's `maxRedemptions`.

## Assignments

An assignment says where a discount applies: the products, the customers and a
window of days. A discount with no assignment applies nowhere, and a discount
can carry several assignments.

| Field | Set | Null, or left out on create |
| --- | --- | --- |
| `productId` | that product | every product, unless `productGroupId` is set |
| `productGroupId` | every product in that product group | no group |
| `customerId` | that customer | every customer, unless `customerGroupId` is set |
| `customerGroupId` | every customer in that customer group | no group |
| `startsAt` | the first day it applies | from any day |
| `endsAt` | the last day it applies | it never ends |

An assignment sets at most one of `productId` and `productGroupId`, and at most
one of `customerId` and `customerGroupId`. A group is read when a document is
checked, so a product or customer moved into the group is covered from then on,
and one moved out is not. Deleting a product group or a customer group deletes
the assignments aimed at it.

`startsAt` and `endsAt` are calendar days (`YYYY-MM-DD`) and both are
inclusive. On `PATCH`, a field left out keeps its value and a field sent as
null clears it.

| Operation | Endpoint | Scope |
| --- | --- | --- |
| List discounts | `GET /v1/discounts` | `products-read` |
| Get a discount | `GET /v1/discounts/{discountId}` | `products-read` |
| Create a discount | `POST /v1/discounts` | `products-write` |
| Update a discount | `PATCH /v1/discounts/{discountId}` | `products-write` |
| Delete a discount | `DELETE /v1/discounts/{discountId}` | `products-write` |
| Set an amount | `PUT /v1/discounts/{discountId}/amounts/{currency}` | `products-write` |
| Remove an amount | `DELETE /v1/discounts/{discountId}/amounts/{currency}` | `products-write` |
| Add an assignment | `POST /v1/discounts/{discountId}/assignments` | `products-write` |
| Change an assignment | `PATCH /v1/discounts/{discountId}/assignments/{assignmentId}` | `products-write` |
| Remove an assignment | `DELETE /v1/discounts/{discountId}/assignments/{assignmentId}` | `products-write` |
| Discounts that apply | `GET /v1/discounts/applicable` | `products-read` |
| Whether one applies | `GET /v1/discounts/{discountId}/applicability` | `products-read` |
| List a discount's codes | `GET /v1/discounts/{discountId}/codes` | `products-read` |
| Add a code | `POST /v1/discounts/{discountId}/codes` | `products-write` |
| Change a code | `PATCH /v1/discounts/{discountId}/codes/{codeId}` | `products-write` |
| Delete a code | `DELETE /v1/discounts/{discountId}/codes/{codeId}` | `products-write` |
| Look up a code | `GET /v1/discounts/codes/{code}` | `discounts-check` or `products-read` |
| Check a cart | `POST /v1/discounts/check` | `discounts-check` or `products-read` |

The list of discounts is paginated (`page` up to 100,000, `limit` up to 100)
and filters by `active`, `type` and `scope`. The list of a discount's codes is
paginated the same way, oldest code first. Full request and response schemas
are in the [API Reference](/api).

## Applicability

A discount applies to a product for a customer on a day when it is active and
one of its assignments matches all three:

- its `productId` is the product, or its `productGroupId` is the product's
  group, or both are null;
- its `customerId` is the customer, or its `customerGroupId` is the customer's
  group, or both are null;
- `startsAt` is null or on or before the day, and `endsAt` is null or on or
  after it.

`GET /v1/discounts/applicable?customerId=…&productId=…&asOf=YYYY-MM-DD` lists
every discount that applies, ordered by priority. A discount whose redemption
limit has no use left, in total or for this customer, is left out. It does not
look at currency, so check `amounts` before referencing a fixed or per-unit
discount on a document in another currency.

`GET /v1/discounts/{discountId}/applicability` takes the same query and
answers for one discount:

```json
{ "applicable": false, "reason": "maxPerCustomerReached", "remaining": 1, "remainingForCustomer": 0 }
```

`remaining` is the uses left under `maxRedemptions` and `remainingForCustomer`
the uses left for this customer under `maxPerCustomer`; each is null when the
discount has no such limit. Asking holds no use, so a booking can still be
refused if another booking takes the last use first. The reasons are checked
in the order of the table:

| `reason` | Meaning |
| --- | --- |
| `null` | It applies. |
| `unknown` | Your company has no discount with this id. |
| `inactive` | The discount has `active: false`. |
| `codeRequired` | The discount has `requiresCode: true`, so it applies only through one of its codes. |
| `notAssigned` | No assignment names this product and customer, or all of them. |
| `notInGroup` | An assignment would match, but the product or the customer is not in the group it targets. |
| `outsideWindow` | An assignment matches the product and customer, but not on this day. |
| `maxRedemptionsReached` | Every use under `maxRedemptions` is taken. |
| `maxPerCustomerReached` | This customer has taken every use under `maxPerCustomer`. |

## Codes

A code is the text a customer types to claim a discount, such as `SUMMER25`
in a webshop's "apply code" field. A discount can carry many codes. The
discount's assignments, window and limits still apply to a code, and the code
can add three limits of its own.

| Field | Meaning |
| --- | --- |
| `code` | 1 to 64 characters with no space inside. Matched without regard to case or surrounding spaces; `normalizedCode` is the upper-cased text a lookup compares. |
| `customerId` | The one customer who may use the code; it must be a customer the discount's assignments cover. Null: any customer the discount applies to. |
| `maxRedemptions` | Uses allowed through this code, on top of the discount's own limits. Null: no limit of its own. |
| `expiresAt` | The last calendar day the code can be used, inclusive (`YYYY-MM-DD`). Null: it never expires. |
| `active` | `false` switches the code off without touching the discount or its other codes. |

Only one active code in your company can hold a given text. Switching a code
off frees its text, so a new code, on this discount or another, can reuse it.
A code that a document references, or that has been used, cannot be deleted;
switch it off instead.

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts/<discountId>/codes' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{ "code": "SUMMER25", "maxRedemptions": 500, "expiresAt": "2026-08-31" }'
```

`GET /v1/discounts/codes/{code}` answers the code a shopper typed, the
discount it claims (without its assignments) and whether it applies. Pass
`customerId` for a known customer; without it the shopper is a guest, to whom
only assignments for every customer apply and a code made for one customer
does not. Pass `productId` to judge one product, and `asOf=YYYY-MM-DD` for a
day other than today.

```bash
curl 'https://api.ledgerbee.com/api/v1/discounts/codes/summer25?customerId=<customerId>&asOf=2026-08-15' \
  -H 'x-api-key: <your-key>'
```

```json
{
  "code": { "id": "0199B6D2-3C1A-7A40-9F00-1D2E3F4A5B6C", "code": "SUMMER25", "normalizedCode": "SUMMER25", "active": true, "uses": 12 },
  "discount": { "id": "0199B6D2-3C1A-7A40-9F00-1D2E3F4A5B6D", "name": "Summer 25%", "type": "percentage", "scope": "line", "percentagePct": "25.0000", "combinable": true, "amounts": [] },
  "applicable": true,
  "reason": null,
  "remaining": null,
  "remainingForCustomer": null,
  "remainingForCode": 488
}
```

The example shows only some of the code's fields; the full shape is in the
[API Reference](/api). A code your company does not have answers `200` with
`reason: codeUnknown` and `code` and `discount` null; a code of another company
answers the same. The lookup checks the discount's own reasons first, then the
code's, in this order:

| `reason` | Meaning |
| --- | --- |
| `codeUnknown` | Your company has no code with this text. |
| `inactive` … `maxPerCustomerReached` | The discount's own reasons, as in [Applicability](#applicability), except `codeRequired`: a code is how a code-only discount applies. |
| `codeInactive` | The code has `active: false`. |
| `codeExpired` | The day is after the code's `expiresAt`. |
| `codeCustomerMismatch` | The code is for another customer, or for a customer and none was given. |
| `codeMaxRedemptionsReached` | Every use under the code's `maxRedemptions` is taken. |

Like the applicability check, a lookup holds no use.

## Codes on an invoice

An invoice takes the codes a customer gave in `discountCodes`, beside
`discountIds`, on `POST /v1/invoices` and `PATCH /v1/invoices/{invoiceId}`.
Send the same list your checkout sent to the [cart check](#check-a-cart), and
the invoice prices the way the check did:

- Each code is matched like the code lookup and claims its discount, which then
  applies as if named by id. A discount with `requiresCode: true` applies this
  way and no other.
- A line-scope discount lands on every line whose product it covers. A
  document-scope one applies to the document.
- A discount named both by id and through a code, or through two codes, applies
  once and carries the first code.

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/invoices' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "<customerId>",
    "date": "2026-08-15",
    "currency": "DKK",
    "lines": [{ "priceId": "<priceId>", "quantity": 2 }],
    "discountCodes": ["summer25"]
  }'
```

Each applied discount in the response carries `code`, the code's text as it was
created, when a code claimed it; a discount named by id has no `code`. The
invoice stores the code, so a credit note of the invoice gives the discount back
with the same code.

A code is judged on the invoice date for the invoice's customer. A code that is
unknown, switched off, expired on that date or made for another customer is
refused with `400 DISCOUNT_NOT_APPLICABLE`: `details.reason` is `codeUnknown`,
`codeInactive`, `codeExpired` or `codeCustomerMismatch`, and `details.code` is
the code as matched. The code's `maxRedemptions` is not judged when the invoice
is saved, only when it is booked, as for the discount's own limits. A booking
that would pass it is refused with `400 DISCOUNT_CODE_REDEMPTION_LIMIT_REACHED`
and the invoice stays a draft. The booked use names the code, so the code's
`uses` and the lookup's `remainingForCode` count it.

An update restates every line. A discount the invoice carries through a code
keeps its code when the update names the discount again by id, so reading an
invoice and writing it back does not need the code. The kept code is judged
again on the update's date and customer. Codes, like `discountIds`, are sent
together with `lines`.

## Codes on quotes and order confirmations

A quote and an order confirmation take `discountCodes` the same way, on
`POST /v1/quotes`, `PATCH /v1/quotes/{id}`, `POST /v1/order-confirmations`
and `PATCH /v1/order-confirmations/{id}`. Each applied discount
carries `code`, and an update that names the discount again keeps its code.
Saving one uses none of the code's limit.

Converting carries each code with its discount, so the customer does not give
the code again:

| Conversion | A code switched off since the quote | A code that expired after the quote's date | A different customer on the payload |
|---|---|---|---|
| `discountPricing: "asQuoted"` | Kept as quoted | Kept as quoted | Kept as quoted |
| `discountPricing: "current"`, or none | `400 DISCOUNT_NOT_APPLICABLE`, `codeInactive` | Applies | `400 DISCOUNT_NOT_APPLICABLE`, `codeCustomerMismatch` |

The code is judged on the date the conversion chain starts at, the same day
the discount is: the quote's date, or an order confirmation's own date when it
was made without a quote. A document converted as quoted keeps the code on
every later edit. A conversion
payload's own `discountCodes` are resolved as on any save.

Quote to order confirmation to invoice carries the code all the way. Only
booking the invoice counts the code's use, and a code at its limit refuses that
booking with `400 DISCOUNT_CODE_REDEMPTION_LIMIT_REACHED`. When a conversion's
response carries a warning about a discount the quote claimed through a code,
the warning names that code in `discountCode`.

## Codes on a subscription

A subscription takes a code in `discountCode` on `POST /v1/subscriptions`, and
a buyer's code in the [partner checkout](/guides/partner-checkout#discount-codes)
quote and session, or on a checkout vouch (`POST /v1/portal-checkout/vouch`).
The code claims its discount for the subscription itself; add-ons created with
it do not take it.

The discount bills on every period that starts before it ends. A discount with
`durationInPeriods` lasts that many billing periods from the first one billed,
so a trial does not use it up. After that the subscription bills at its
regular price. A worked example, from the code to the invoices, is in
[Partner-Hosted Checkout](/guides/partner-checkout#example-a-first-month-discount).

The subscription takes one use of the discount and one of the code when it is
created, whatever number of periods it bills. The limits are judged then,
under a lock on the discount, and a code with no use left is refused with
`400 DISCOUNT_REDEMPTION_LIMIT_REACHED`,
`DISCOUNT_CUSTOMER_REDEMPTION_LIMIT_REACHED` or
`DISCOUNT_CODE_REDEMPTION_LIMIT_REACHED`; nothing is created. A checkout also
judges them when it quotes and when it opens the card window, so a buyer is
told before a card is asked for.

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/subscriptions' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{ "customerId": "<customerId>", "planId": "<planId>", "startDate": "2026-11-01", "discountCode": "WELCOME10" }'
```

## Codes on a pricing page

A buyer can enter a code before choosing a plan. The plan catalogue reads take
it as `discountCode`: a query parameter on `GET /v1/portal/plans` and
`GET /v1/portal/plans/{planId}`, and a body field on
`POST /v1/portal/plans/resolve`. Each subscription card then carries
`discount`, the code priced for that card's first period exactly as the
[checkout quote](/guides/partner-checkout#discount-codes) prices it for a
checkout starting today:

| Field | Meaning |
| --- | --- |
| `firstPeriodNetMinor` | The card's first invoice with the code, before VAT, in minor units of the card's `currency`. One-time charges on that invoice are included. |
| `firstPeriodAmountOffMinor` | What the code takes off that invoice, before VAT. Positive; 0 when it touches none of the card's lines. |
| `endsAt` | `YYYY-MM-DD`: a period starting on or after this day bills at the regular price. Null when the discount has no end. |
| `code`, `name`, `type`, `durationInPeriods` | As on the quote. |

A card the code cannot price carries `discount: null` - a call-to-action card,
or a fixed discount with no amount in the card's currency. A code that is
unknown, used up or not for this buyer refuses the whole read with its
`DISCOUNT_*` code, as the quote does. On `resolve` with a `customerId`, that
customer's own uses count against the discount's per-customer limit. Without
`discountCode`, no card carries `discount` and the read is unchanged.

A read naming a code spends one use of the code-attempt budget the quote and
the session share (see [Rate limits](/guides/rate-limits)). Pass the code on to
the checkout quote and session when the buyer picks a plan; it is judged again
there, and again under the discount's lock when the subscription is created.

```bash
curl 'https://api.ledgerbee.com/api/v1/portal/plans?discountCode=WELCOME10' \
  -H 'x-api-key: <your-key>'
```

The hosted portal's plans page does the same: the buyer enters the code above
the plans, each plan shows its first period with the code and the day the
regular price starts, and the code carries into the checkout the buyer opens.

## Check a cart

`POST /v1/discounts/check` prices a webshop cart before any order exists: what
each discount and code takes off the lines, the ones that do not apply and why,
and the uses left. It creates no document and holds no use. Booking the invoice
counts the use and checks every limit again, so a cart that checked clean can
still be refused at booking when the last use went to someone else.

| Field | Meaning |
| --- | --- |
| `customerId` | The customer buying. Omitted: a guest, to whom only assignments for every customer apply, a code made for one customer does not apply, and no per-customer use is counted. |
| `currency` | The cart's currency (ISO 4217). A fixed or per-unit discount needs an amount in it. |
| `date` | The calendar day the cart is priced on (`YYYY-MM-DD`). |
| `lines` | 1 to 200 lines of `productId`, `quantity` (negative for a returned item), `unitPrice` (a decimal string) and an optional `vatRatePct`. |
| `codes` | Up to 20 codes the shopper typed, matched like the code lookup. |
| `discountIds` | Up to 50 discounts applied by id. |

The discounts in `discountIds` apply first, then the codes, each in the order
sent. A discount named twice, by id and by a code or by two codes, applies once.

A line discount is named once for the whole cart and lands on every line whose
product it covers, so a Widget-only code takes its share off each Widget line. A
document discount is taken from the net of the lines it covers, a returned line
included: on Widget 1,000, Gadget 500 and a returned Widget −200, a 10%
whole-order code takes 130.

Send `vatRatePct` on every line to get each document discount's split by VAT
rate. When a line the discount falls on has none, `vatSplitAvailable` is `false`
and `vatSplit` is null; the reduction itself is still returned.

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts/check' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "<customerId>",
    "currency": "DKK",
    "date": "2026-11-27",
    "lines": [
      { "productId": "<widgetId>", "quantity": 1, "unitPrice": "1000.00", "vatRatePct": 25 },
      { "productId": "<gadgetId>", "quantity": 1, "unitPrice": "500.00", "vatRatePct": 25 },
      { "productId": "<widgetId>", "quantity": -1, "unitPrice": "200.00", "vatRatePct": 25 }
    ],
    "codes": ["order10", "nosuchcode"]
  }'
```

```json
{
  "lines": [
    { "lineIndex": 0, "discounts": [], "originalNet": "1000.00" },
    { "lineIndex": 1, "discounts": [], "originalNet": "500.00" },
    { "lineIndex": 2, "discounts": [], "originalNet": "-200.00" }
  ],
  "documentDiscounts": [
    {
      "discountId": "0199B6D2-3C1A-7A40-9F00-1D2E3F4A5B6E",
      "code": "ORDER10",
      "originalReductionAmount": "130.00",
      "vatSplitAvailable": true,
      "vatSplit": [{ "vatRatePct": "25.00", "originalReductionAmount": "130.00", "originalVatReductionAmount": "32.50" }]
    }
  ],
  "refusals": [{ "code": "NOSUCHCODE", "discountId": null, "reason": "codeUnknown", "lineIndex": null }],
  "limits": [
    { "discountId": "0199B6D2-3C1A-7A40-9F00-1D2E3F4A5B6E", "code": "ORDER10", "remaining": null, "remainingForCustomer": null, "remainingForCode": null }
  ]
}
```

Each line's `originalNet` is the line after its line discounts and before any
document discount. Every amount is in the cart's currency.

A code or discount that does not apply comes back in `refusals` while the rest
of the cart is still priced. Its `reason` is one of the reasons in
[Applicability](#applicability) and [Codes](#codes), or the error code a
document referencing it would be refused with:

| `reason` | Meaning |
| --- | --- |
| `codeUnknown` … `codeMaxRedemptionsReached` | The code's own reasons, as for the code lookup. |
| `unknown` | Your company has no discount with an id in `discountIds`. |
| `inactive` … `maxPerCustomerReached` | The discount's reasons, as in [Applicability](#applicability). A line discount that covers none of the cart's lines gives the reason for the line closest to applying. |
| `DISCOUNT_NO_AMOUNT_IN_CURRENCY` | A fixed or per-unit discount has no amount in the cart's currency. |
| `DISCOUNT_NOT_COMBINABLE` | A discount that may not be combined shares a line or the cart with another; `lineIndex` names the line, and is null when the two are document discounts. |
| `DISCOUNT_EXCEEDS_LINE` / `DISCOUNT_EXCEEDS_DOCUMENT` | The discount would take the line, or the lines it covers, below zero. |

`limits` has one row per discount of your company that the cart named (an
`unknown` id or a `codeUnknown` code has none): `remaining` and
`remainingForCustomer` as in [Applicability](#applicability) (the latter null
for a guest), and `remainingForCode` for a discount claimed through a code with
its own limit.

The cart check and the code lookup share a budget of 600 uses a minute per API
key; an OAuth caller is counted per grant, or per OAuth client for a token
issued without a grant. A lookup uses one. A cart check uses one per code it
names, and one when it names no code, so a check with 20 codes uses 20. A check
that needs more than is left is refused whole, and none of its codes is tried.
Past the budget they answer `429` with code
`DISCOUNT_CHECK_RATE_LIMITED` and a `Retry-After` header giving the seconds to
wait. A `POST /v1/subscriptions`, a checkout quote or session, or a checkout
vouch that names a `discountCode` uses one from the same budget. The budget
stops one key from guessing codes.

## Worked example

A 10% loyalty discount for one customer, on every product, with no end date:

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Loyalty 10%", "type": "percentage", "scope": "line", "percentagePct": "10" }'
```

Assign it to the customer, leaving the product and the dates out:

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts/<discountId>/assignments' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{ "customerId": "<customerId>" }'
```

A Black Friday discount of 50 DKK or 8 EUR off each document, for every
customer and every product, from 27 to 30 November:

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/discounts' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Black Friday",
    "type": "fixed",
    "scope": "document",
    "amounts": [
      { "currency": "DKK", "amount": "50.00" },
      { "currency": "EUR", "amount": "8.00" }
    ]
  }'

curl -X POST 'https://api.ledgerbee.com/api/v1/discounts/<discountId>/assignments' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{ "startsAt": "2026-11-27", "endsAt": "2026-11-30" }'
```

On 28 November, `GET /v1/discounts/applicable` for the loyalty customer
returns both discounts. For any other customer it returns Black Friday only,
and on 1 December it returns the loyalty discount only. A document in SEK that
referenced Black Friday would be refused, because the discount has no SEK
amount.

## Deleting and deactivating

`DELETE /v1/discounts/{discountId}` removes a discount with its amounts and
assignments. Once a document or a redemption records the discount, delete is
refused with `DISCOUNT_IN_USE`; send `"active": false` instead, which stops it
applying anywhere and keeps the record the documents point to.

## Discounts on subscriptions

A discount may name a product that plans and customer subscriptions carry, and a
plan or subscription may carry a product a discount names. A customer
subscription applies a discount only when it references it, by id or through a
code ([Codes on a subscription](#codes-on-a-subscription)); an assignment alone
never reaches a subscription's invoices.

Reference discounts when you create the subscription, with `discountIds` on
[`POST /v1/subscriptions`](/guides/subscriptions/assign), or change them later with
`discountIds` and `discountEndDates` on a
[product change](/guides/subscriptions/changes#change-products). Each
referenced discount bills on every billing period that starts before its end
date. A discount with `durationInPeriods` gets an end date that many periods
after the first period billed, counted after any trial; one without has no end
date unless you set one. The subscription takes one use of each discount's
limits when the discount is attached, however many periods it bills, so a limit
is refused when you attach, never at a billing run.

A subscription's `discounts` lists what it references, in order, each with the
end date it carries:

```json
{
  "discounts": [
    {
      "discountId": "0199A2B4-6C1E-7F00-8D2A-3B4C5D6E7F80",
      "name": "Welcome 10%",
      "type": "percentage",
      "scope": "document",
      "percentagePct": "10",
      "amountInCurrency": null,
      "active": true,
      "endsAt": "2027-01-01",
      "targetsNoProduct": false
    }
  ]
}
```

A plan replacement keeps the references. `targetsNoProduct` is true for a
line-scope discount that names none of the subscription's products: it takes
nothing off until one of its products comes back or you assign it to one of
these.

A subscription bills each period on the discount's terms as they were when that
period's version started: what it takes off, its amount in the subscription's
currency, its scope and whether it combines. So an edit to those terms cannot
reach a period that is already running.

`PATCH /v1/discounts/{discountId}`, `PUT /v1/discounts/{discountId}/amounts/{currency}`
and `DELETE /v1/discounts/{discountId}/amounts/{currency}` refuse such an edit
with `409 DISCOUNT_IN_USE_ON_SUBSCRIPTIONS` while live subscriptions reference
the discount, and save nothing. `details.total` counts the subscriptions and
`details.subscriptions` lists them when your key has `subscriptions-read`;
without it the list is empty, and the `name` and `customerName` of each entry
in `subscriptionChanges` below are `null`. To make the edit, send
`"applyToSubscriptions": true` in the body (on the `DELETE`, the query
`?applyToSubscriptions=true`); accepting needs a key with `subscriptions-write`,
and without it the edit is refused as above. The edit is saved, and each subscription takes
the new terms from its next billing period as a new version:

```json
{
  "id": "0199A2B4-6C1E-7F00-8D2A-3B4C5D6E7F80",
  "percentagePct": "20",
  "warnings": [],
  "subscriptionChanges": [
    {
      "customerSubscriptionId": "0199A2B4-9F11-7A20-8C3D-4E5F60718293",
      "chainHeadId": "0199A2B4-7A10-7B30-9E4F-5A6B7C8D9E0F",
      "name": "Support plan",
      "customerName": "Nordlys ApS",
      "effectiveDate": "2026-11-01",
      "outcome": "scheduled",
      "errorCode": null
    }
  ]
}
```

A subscription that cannot take the new version is listed with
`"outcome": "failed"` and the reason in `errorCode`; it keeps billing the old
terms, and the other subscriptions and the saved edit are not affected. A
rename, a priority or limit change, an amount in a currency none of the
subscriptions bills in, and switching the discount on or off are not refused:
the first ones change nothing a subscription bills, and whether a discount is
active is read when each period is billed.

## Errors

Errors use the standard envelope described in [Errors](/guides/errors), with
the code in `code`.

| Condition | What you observe | Fix |
| --- | --- | --- |
| No discount with this id in your company | `404 DISCOUNT_NOT_FOUND` | Check the id; discounts of other companies are not visible. |
| The assignment does not exist on this discount | `404 DISCOUNT_ASSIGNMENT_NOT_FOUND` | Read the discount and use an id from `assignments`. |
| An assignment names a product, customer, product group or customer group that does not exist in your company | `404 DISCOUNT_PRODUCT_NOT_FOUND` / `404 DISCOUNT_CUSTOMER_NOT_FOUND` / `404 DISCOUNT_PRODUCT_GROUP_NOT_FOUND` / `404 DISCOUNT_CUSTOMER_GROUP_NOT_FOUND` | Use an id from your own catalogue or customer list. The applicability reads do not look the ids up: an unknown product or customer belongs to no group, so it matches only an assignment that names no product (or no customer) and no group. |
| No amount in that currency to remove | `404 DISCOUNT_AMOUNT_NOT_FOUND` | Read `amounts` first. |
| `percentagePct` is 0, negative, above 100 or has more than 4 decimals | `400 DISCOUNT_INVALID_PERCENTAGE` | Send a value above 0 and at most 100, with at most 4 decimals. |
| `percentagePct` sent for a fixed or per-unit discount | `400 DISCOUNT_PERCENTAGE_NOT_ALLOWED` | State amounts per currency instead. |
| Amounts sent for a percentage discount | `400 DISCOUNT_AMOUNT_NOT_ALLOWED` | Leave `amounts` out. |
| An amount of 0 or below, or with more than 12 digits before the decimal point | `400 DISCOUNT_INVALID_AMOUNT` | Send a positive amount below 1,000,000,000,000. |
| The same currency twice in `amounts` | `400 DISCOUNT_DUPLICATE_CURRENCY` | Send each currency once. |
| A currency LedgerBee does not support | `400 DISCOUNT_UNSUPPORTED_CURRENCY` | Use a supported ISO 4217 code. |
| A different `type` on update | `400 DISCOUNT_TYPE_IMMUTABLE` | Create a new discount of the other type. |
| A `per_unit` discount with `document` scope, on create or update | `400 DISCOUNT_PER_UNIT_NEEDS_LINE_SCOPE` | Use `line` scope, or a `fixed` discount for an amount off the document. |
| `maxRedemptions` or `maxPerCustomer` below 1 | `400 DISCOUNT_INVALID_LIMIT` | Send a whole number of at least 1, or null. |
| `startsAt` after `endsAt` | `400 DISCOUNT_INVALID_WINDOW` | Swap or correct the dates. |
| An assignment holding both `productId` and `productGroupId`, or both `customerId` and `customerGroupId`, after the write | `400 DISCOUNT_ASSIGNMENT_TARGET_CONFLICT` (`details.pair` is `product` or `customer`) | Set one of the pair; on `PATCH`, send null for the other in the same request. |
| Booking an invoice that would pass the discount's `maxRedemptions` | `400 DISCOUNT_REDEMPTION_LIMIT_REACHED` | Remove the discount from the invoice or raise the limit. |
| Booking an invoice that would pass the customer's `maxPerCustomer` | `400 DISCOUNT_CUSTOMER_REDEMPTION_LIMIT_REACHED` | Remove the discount from the invoice or raise the limit. |
| Deleting a discount a document or redemption records | `409 DISCOUNT_IN_USE` | Deactivate it with `"active": false`. |
| Adding a code, or switching one back on, while another active code holds the same text | `409 DISCOUNT_CODE_TAKEN` | Switch the other code off first, or choose another text. |
| A code that is empty, longer than 64 characters or has a space inside | `400 DISCOUNT_CODE_INVALID` | `details.reason` is `empty`, `tooLong` or `whitespace`. |
| A code for a customer the discount's assignments do not cover | `400 DISCOUNT_CODE_CUSTOMER_NOT_ASSIGNED` | Assign the discount to the customer first, or leave `customerId` out. |
| The code does not exist on this discount | `404 DISCOUNT_CODE_NOT_FOUND` | List the discount's codes and use an id from there. |
| More than 600 uses in a minute from one API key, or from one OAuth grant (one OAuth client for a token issued without a grant): a code lookup uses one, a cart check one per code it names (one when it names none), and a `POST /v1/subscriptions`, a checkout quote or session, or a checkout vouch that names a `discountCode` uses one | `429 DISCOUNT_CHECK_RATE_LIMITED` | Wait the seconds in `Retry-After`. |
| A cart check names a `customerId` your company does not have | `404 DISCOUNT_CUSTOMER_NOT_FOUND` | Send a customer of your company, or leave `customerId` out for a guest. |
| Deleting a code a document references or that has been used | `409 DISCOUNT_CODE_IN_USE` | Switch it off with `"active": false`. |
| A code in an invoice's, quote's or order confirmation's `discountCodes` (or carried by a conversion "current") that is unknown, switched off, expired on the document date (for a conversion, the date its chain starts at) or for another customer | `400 DISCOUNT_NOT_APPLICABLE` (`details.reason`, `details.code`) | Remove the code, or correct the customer or the date. |
| Booking an invoice that would pass the `maxRedemptions` of the code it claimed a discount through | `400 DISCOUNT_CODE_REDEMPTION_LIMIT_REACHED` (`details.discountCodeId`, `details.code`, `details.limit`, `details.used`) | Remove the code from the invoice or raise the code's limit. |
| An edit that changes what the discount takes off while live subscriptions reference it, without `applyToSubscriptions` | `409 DISCOUNT_IN_USE_ON_SUBSCRIPTIONS` (`details.total`, `details.subscriptions`) | Send `applyToSubscriptions: true` to move them onto the new terms from their next billing period, or keep the discount as it is. |
| A malformed body field or query parameter, including an id or a date in them | `400` with `details.validationErrors` | Correct the field named in the error. |
| A `discountId` or `assignmentId` in the path that is not a UUID | `400` | Send the id as a UUID. |
| The key lacks the scope | `403 INSUFFICIENT_PERMISSIONS` | Add the scope the endpoint names: `products-read` for a read, `products-write` for a write (it does not include reads). `discounts-check` reaches only the code lookup and the cart check. |
