API referenceTenvelo API
https://api.tenvelo.com The contract, as OpenAPI

Contract 2026-10-10. Every path below carries its own major version.

Catalog · Reference

Discounts

The reductions a merchant offers, and when each one is on offer. Server-authoritative and priced master data like the catalogue, so it is filed beside it: the till and the ordering site both read discounts and neither writes them.

Operations
5
Base
https://api.tenvelo.com
  1. GET/v1/discounts

    List discounts

    No. listDiscountsAuth catalog:read

    Returns the merchant's discounts, active and inactive, so a caller can show a paused promotion alongside the ones being offered.

    Parameters

    • cursorquery · string

      Opaque cursor from a previous page's next_cursor. Omit to start from the beginning.

      Pass it back exactly as received. It is base64 and its structure is not part of this contract — decoding it, or constructing one by hand, will break without notice.

    • limitquery · integer · int32

      Maximum items to return. The server may return fewer, so always drive the loop from has_more rather than from the size of the page you got back.

    Responses

    • 200

      A page of discounts.

      DiscountPage · application/json
      • has_moreboolean

        Required

        Whether another page follows.

      • itemsarray of Discount

        Required

        • idstring · uuid

          Required

        • kindstring · enum

          Required

          The arithmetic. percentage takes percentage off the gross; fixed takes the amount off; spend is a spend-and-save reward (retail promotions) — when the order's subtotal reaches threshold, the percentage percentage or the amount comes off the whole order, applied by the till with no staff action. Exactly one of the two value fields is meaningful, and which one is decided here rather than inferred from whichever is set. A spend discount must be order scope.

          percentagefixedspend

        • namestring

          Required

          What staff see in the discount picker, and what the receipt prints.

          1–128 chars

        • amountMoney

          The amount off for a fixed discount, in the merchant's currency. Required when kind is fixed and ignored otherwise; the currency is the merchant's and is set by the server, so a fixed discount cannot be created in a currency the merchant does not trade in.

          • amountinteger · int64

            Required

            The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

          • currencyCurrency

            Required

            ISO 4217 currency code.

            Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

            One of 182 values; see the schema.

        • created_atTimestamp

          RequiredRead only

          Server-assigned RFC 3339 timestamp, always UTC.

          Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in client_created_at.

        • merchant_idstring · uuid

          RequiredRead only

        • percentagePercentage

          A share of a whole, from "0" to "100", as a percentage in a decimal string with at most four decimal places, as Square's percentage and Stripe Tax's percentage_decimal are: UK VAT at 20% is "20", and an 8.875% US sales tax is "8.875".

          A string because a JSON number is a binary float to most parsers, and a float cannot hold most decimal fractions exactly (RFC 7493 §2.2). Tenvelo computes with a percentage as an integer in parts per million ("8.875" is 88750), which every four-place percentage is exactly. A client that computes with one should read the digits the same way, never through a float.

          pattern ^(100(\.0{1,4})?|(0|[1-9][0-9]?)(\.[0-9]{1,4})?)$

        • schedulearray of DaypartWindow

          The windows the discount is offered in. Empty or absent is an always-on discount, offered whenever staff pick it. A scheduled discount is the only kind that applies itself, with no staff action, which is why a manual staff rate must be left unscheduled.

          ≤ 8 items

          • daysarray of integer

            The weekdays this window opens on, 1 (Monday) through 7 (Sunday). An empty or absent list is every day.

            ≤ 7 items

          • end_minuteinteger

            Required

            1–1440

          • start_minuteinteger

            Required

            0–1439

        • scopestring · enum

          Required

          Whether the discount comes off one line (line_item) or the whole order (order). A till offers a line discount on the line it is on and an order discount at the total. Required rather than defaulted: the two are offered in different places on the till, so a discount that did not say which would be offered in the wrong one.

          line_itemorder

        • statusstring · enum

          Required

          inactive pauses a discount without losing it — a seasonal promotion is deactivated rather than deleted, so it can be brought back without being recreated. Devices are sent both, and offer only the active ones.

          activeinactive

        • thresholdMoney

          The subtotal a spend reward starts at, in the merchant's currency. Required when kind is spend and ignored otherwise; a basket below it earns nothing.

          • amountinteger · int64

            Required

            The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

          • currencyCurrency

            Required

            ISO 4217 currency code.

            Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

            One of 182 values; see the schema.

        • updated_atTimestamp

          RequiredRead only

          Server-assigned RFC 3339 timestamp, always UTC.

          Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in client_created_at.

        • versionVersion

          RequiredRead only

          Optimistic concurrency version, raised by the server on every write. A version is never lower than one already returned for the resource, though it may rise by more than one.

          Returned in the ETag response header as well as in the body. Send it back in If-Match on an update and the write is rejected with 412 if anyone else has written since — which is what stops two tills silently overwriting each other's edit to the same product when both come back online at once.

          ≥ 1

      • next_cursorstring

        Nullable

        Opaque cursor for the next page, or null on the last page. Pass it back verbatim as the cursor query parameter; its contents are not part of this contract.

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 422

      The request parsed but failed validation.

      invalid_params lists every failing field with a JSON Pointer, not just the first, so a client can mark up a whole form in one pass rather than making the user fix errors one round trip at a time.

      Also returned when an Idempotency-Key is replayed with a different body, and when device-computed order totals disagree with the server's recomputation.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.

  2. POST/v1/discounts/batch-upsert

    Create or replace discounts

    No. batchUpsertDiscountsAuth catalog:write

    Creates discounts at client-minted ids, or replaces existing ones at the version they were read at. A batch of one is how a single discount is created.

    Each item carries its own idempotency_key and stands or falls on its own: an invalid item, or one written from a stale version, fails in its result and every other item is still applied. Only a malformed envelope refuses the request as a whole. Every change reaches devices through the change feed.

    A fixed discount is stored in the merchant's own currency whatever currency the item names, because a discount a merchant cannot trade in is not a discount.

    Request body application/json · DiscountBatchUpsertRequest

    • itemsarray of DiscountBatchUpsertItem

      Required

      ≥ 1 items · ≤ 500 items

      • discountDiscount

        Required

        A named reduction a merchant offers — "10% off", "£1 off a coffee", a staff rate.

        Server-authoritative, like the catalogue: it is configured once and reaches every till and the ordering site through the change feed. A device applies it when ringing up and records on the sale which discount was applied, so a receipt reprints what was actually offered rather than whatever the list says now.

        A discount is either a percentage of the gross or a fixed amount off, never both, and kind says which. A fixed discount is held in the merchant's own currency and is clamped to the gross when applied, because a line cannot go negative.

        • idstring · uuid

          Required

        • kindstring · enum

          Required

          The arithmetic. percentage takes percentage off the gross; fixed takes the amount off; spend is a spend-and-save reward (retail promotions) — when the order's subtotal reaches threshold, the percentage percentage or the amount comes off the whole order, applied by the till with no staff action. Exactly one of the two value fields is meaningful, and which one is decided here rather than inferred from whichever is set. A spend discount must be order scope.

          percentagefixedspend

        • namestring

          Required

          What staff see in the discount picker, and what the receipt prints.

          1–128 chars

        • amountMoney

          The amount off for a fixed discount, in the merchant's currency. Required when kind is fixed and ignored otherwise; the currency is the merchant's and is set by the server, so a fixed discount cannot be created in a currency the merchant does not trade in.

          • amountinteger · int64

            Required

            The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

          • currencyCurrency

            Required

            ISO 4217 currency code.

            Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

            One of 182 values; see the schema.

        • percentagePercentage

          A share of a whole, from "0" to "100", as a percentage in a decimal string with at most four decimal places, as Square's percentage and Stripe Tax's percentage_decimal are: UK VAT at 20% is "20", and an 8.875% US sales tax is "8.875".

          A string because a JSON number is a binary float to most parsers, and a float cannot hold most decimal fractions exactly (RFC 7493 §2.2). Tenvelo computes with a percentage as an integer in parts per million ("8.875" is 88750), which every four-place percentage is exactly. A client that computes with one should read the digits the same way, never through a float.

          pattern ^(100(\.0{1,4})?|(0|[1-9][0-9]?)(\.[0-9]{1,4})?)$

        • schedulearray of DaypartWindow

          The windows the discount is offered in. Empty or absent is an always-on discount, offered whenever staff pick it. A scheduled discount is the only kind that applies itself, with no staff action, which is why a manual staff rate must be left unscheduled.

          ≤ 8 items

          • daysarray of integer

            The weekdays this window opens on, 1 (Monday) through 7 (Sunday). An empty or absent list is every day.

            ≤ 7 items

          • end_minuteinteger

            Required

            1–1440

          • start_minuteinteger

            Required

            0–1439

        • scopestring · enum

          Required

          Whether the discount comes off one line (line_item) or the whole order (order). A till offers a line discount on the line it is on and an order discount at the total. Required rather than defaulted: the two are offered in different places on the till, so a discount that did not say which would be offered in the wrong one.

          line_itemorder

        • statusstring · enum

          Required

          inactive pauses a discount without losing it — a seasonal promotion is deactivated rather than deleted, so it can be brought back without being recreated. Devices are sent both, and offer only the active ones.

          activeinactive

        • thresholdMoney

          The subtotal a spend reward starts at, in the merchant's currency. Required when kind is spend and ignored otherwise; a basket below it earns nothing.

          • amountinteger · int64

            Required

            The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

          • currencyCurrency

            Required

            ISO 4217 currency code.

            Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

            One of 182 values; see the schema.

      • idempotency_keyItemIdempotencyKey

        Required

        A key for one item in a batch, minted by the client when it queues the item and reused on every attempt to send it. The item, not the request, is the unit of work: a retried batch returns the stored result for every item already applied under its key. A fresh key per attempt defeats the whole mechanism.

        Scoped per merchant, so keys need only be unique within a merchant's own traffic.

        16–255 chars

      • versioninteger · int64

        The version this write was made from. Required to replace a discount that exists, and refused as 412 when somebody else has written it since.

        ≥ 1

    Responses

    • 200

      The batch was processed. results says what happened to each item.

      BatchResponse · application/json
      • resultsarray of BatchResult

        Required

        • idstring · uuid

          The resource the item created or changed. Absent when it failed.

        • idempotency_keystring

          Required

          The item's key, echoed so a result can be matched by key as well as position.

        • problemProblem

          Nullable

          Why the item failed, as an RFC 9457 problem. A 422 means the item is malformed and will never succeed — drop it and show it to staff rather than retrying for ever. A 409 or a 5xx means try again later.

          • typestring · uri

            Required

            A stable URI identifying the problem kind. This is the field to branch on.

            default about:blank

          • api_versionstring

            Present on an api-version-retired or api-version-unknown refusal: the API-Version this server speaks (docs/decisions/0011).

          • detailstring

            A human-readable explanation specific to this occurrence. Safe to show to a developer; never contains internal state, SQL, or stack traces.

          • instancestring · uri-reference

            A URI reference identifying the specific occurrence.

          • invalid_paramsarray of InvalidParam

            Present on 422 responses. Lists every field that failed validation, rather than only the first, so a client can highlight an entire form in one pass.

            • codestring

              Stable machine-readable code for this failure, for clients that localise their own messages rather than showing reason.

            • pointerstring

              Required

              RFC 6901 JSON Pointer to the offending value within the request body. A pointer rather than a field name so that failures nested inside arrays — the third line item of an order — are addressable at all.

            • reasonstring

              Required

              Why this value was rejected, in human-readable form.

          • refusalsarray of Location refusal

            Present on 409 responses to a withdrawal refused by the state it depends on (deleting a location with tills still assigned, open orders, …). Lists every reason at once, each with how much stands in the way, so a client can say what to put right and link to it rather than walking a person through one refusal after another.

            • kindstring

              Required

              A stable refusal kind — devices, open_orders, drawer_sessions, bookings or stock — that a client links to the screen that puts it right. Branch on this field, never on the surrounding prose.

            • countinteger

              Required

              How many things stand in the way of the withdrawal, so a client can repeat the number rather than re-read prose.

              ≥ 1

          • statusinteger

            Required

            The HTTP status code, repeated here so the body is self-contained.

            100–599

          • titlestring

            Required

            A short, human-readable summary of the problem kind.

          • trace_idstring

            The OpenTelemetry trace id for this request. Quote it in a support ticket and the whole request can be pulled up.

        • statusstring · enum

          Required

          created — the item made a new resource. updated — it changed an existing one.

          unchanged — this key was already applied and the original outcome stands. A success, not an error: it is what a client retrying a batch it was unsure landed should expect, and it clears the item from its queue exactly as created does.

          failed — the item was refused, and nothing else in the batch was affected. See problem.

          createdupdatedunchangedfailed

        • versioninteger · int64

          The resource's version after the item. Absent when it failed.

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 415

      The request Content-Type is not supported. This API accepts application/json on all mutations.

    • 422

      The request parsed but failed validation.

      invalid_params lists every failing field with a JSON Pointer, not just the first, so a client can mark up a whole form in one pass rather than making the user fix errors one round trip at a time.

      Also returned when an Idempotency-Key is replayed with a different body, and when device-computed order totals disagree with the server's recomputation.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.

  3. GET/v1/discounts/{id}

    Get a discount

    No. getDiscountAuth catalog:read

    Returns a single discount, whether it is being offered or paused.

    Parameters

    • idpath · string · uuid

      Required

      UUIDv7 of the resource.

      For resources a device can originate — orders, and their line items — this id is minted by the client before the record is ever synced, which is what makes an offline create idempotent.

    Responses

    • 200

      The discount.

      Discount · application/json
      • idstring · uuid

        Required

      • kindstring · enum

        Required

        The arithmetic. percentage takes percentage off the gross; fixed takes the amount off; spend is a spend-and-save reward (retail promotions) — when the order's subtotal reaches threshold, the percentage percentage or the amount comes off the whole order, applied by the till with no staff action. Exactly one of the two value fields is meaningful, and which one is decided here rather than inferred from whichever is set. A spend discount must be order scope.

        percentagefixedspend

      • namestring

        Required

        What staff see in the discount picker, and what the receipt prints.

        1–128 chars

      • amountMoney

        The amount off for a fixed discount, in the merchant's currency. Required when kind is fixed and ignored otherwise; the currency is the merchant's and is set by the server, so a fixed discount cannot be created in a currency the merchant does not trade in.

        • amountinteger · int64

          Required

          The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

        • currencyCurrency

          Required

          ISO 4217 currency code.

          Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

          One of 182 values; see the schema.

      • created_atTimestamp

        RequiredRead only

        Server-assigned RFC 3339 timestamp, always UTC.

        Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in client_created_at.

      • merchant_idstring · uuid

        RequiredRead only

      • percentagePercentage

        A share of a whole, from "0" to "100", as a percentage in a decimal string with at most four decimal places, as Square's percentage and Stripe Tax's percentage_decimal are: UK VAT at 20% is "20", and an 8.875% US sales tax is "8.875".

        A string because a JSON number is a binary float to most parsers, and a float cannot hold most decimal fractions exactly (RFC 7493 §2.2). Tenvelo computes with a percentage as an integer in parts per million ("8.875" is 88750), which every four-place percentage is exactly. A client that computes with one should read the digits the same way, never through a float.

        pattern ^(100(\.0{1,4})?|(0|[1-9][0-9]?)(\.[0-9]{1,4})?)$

      • schedulearray of DaypartWindow

        The windows the discount is offered in. Empty or absent is an always-on discount, offered whenever staff pick it. A scheduled discount is the only kind that applies itself, with no staff action, which is why a manual staff rate must be left unscheduled.

        ≤ 8 items

        • daysarray of integer

          The weekdays this window opens on, 1 (Monday) through 7 (Sunday). An empty or absent list is every day.

          ≤ 7 items

        • end_minuteinteger

          Required

          1–1440

        • start_minuteinteger

          Required

          0–1439

      • scopestring · enum

        Required

        Whether the discount comes off one line (line_item) or the whole order (order). A till offers a line discount on the line it is on and an order discount at the total. Required rather than defaulted: the two are offered in different places on the till, so a discount that did not say which would be offered in the wrong one.

        line_itemorder

      • statusstring · enum

        Required

        inactive pauses a discount without losing it — a seasonal promotion is deactivated rather than deleted, so it can be brought back without being recreated. Devices are sent both, and offer only the active ones.

        activeinactive

      • thresholdMoney

        The subtotal a spend reward starts at, in the merchant's currency. Required when kind is spend and ignored otherwise; a basket below it earns nothing.

        • amountinteger · int64

          Required

          The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

        • currencyCurrency

          Required

          ISO 4217 currency code.

          Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

          One of 182 values; see the schema.

      • updated_atTimestamp

        RequiredRead only

        Server-assigned RFC 3339 timestamp, always UTC.

        Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in client_created_at.

      • versionVersion

        RequiredRead only

        Optimistic concurrency version, raised by the server on every write. A version is never lower than one already returned for the resource, though it may rise by more than one.

        Returned in the ETag response header as well as in the body. Send it back in If-Match on an update and the write is rejected with 412 if anyone else has written since — which is what stops two tills silently overwriting each other's edit to the same product when both come back online at once.

        ≥ 1

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 404

      No such resource, or none visible to this caller.

      Also returned instead of 403 where acknowledging existence would itself leak information — so a 404 does not prove the id is unused globally, only that it is not yours.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.

  4. PATCH/v1/discounts/{id}

    Change a discount

    No. updateDiscountAuth catalog:write

    Applies a JSON Merge Patch (RFC 7396) to a discount, guarded by If-Match.

    Send only the fields that change. The patch is refused with 412 if the discount has changed since the version you read. Setting status to inactive is how a promotion is paused: it stops being offered on every device at the next feed pull, and sales already made keep the discount they were sold under, which is snapshotted onto the line.

    Parameters

    • idpath · string · uuid

      Required

      UUIDv7 of the resource.

      For resources a device can originate — orders, and their line items — this id is minted by the client before the record is ever synced, which is what makes an offline create idempotent.

    • If-Matchheader · string

      Required

      The ETag from the read that produced the value you are updating.

      Required on updates, not optional, because "last write wins" is the wrong default for a catalogue two managers might edit at once. If anything has been written since your read, the request is rejected with 412 and you re-read and re-apply.

      Use * to mean "write regardless of current version" — an explicit, greppable override rather than the silent default.

    Request body application/merge-patch+json · DiscountPatch

    • kindstring · enum

      Changing the kind changes which value field is read, so send the new kind and its value together. A percentage with no percentage is refused rather than silently taking nothing off.

      percentagefixedspend

    • namestring

      1–128 chars

    • amountMoney

      The amount off, for a fixed discount. The currency is the merchant's and is set by the server.

      • amountinteger · int64

        Required

        The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

      • currencyCurrency

        Required

        ISO 4217 currency code.

        Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

        One of 182 values; see the schema.

    • percentagePercentage

      A share of a whole, from "0" to "100", as a percentage in a decimal string with at most four decimal places, as Square's percentage and Stripe Tax's percentage_decimal are: UK VAT at 20% is "20", and an 8.875% US sales tax is "8.875".

      A string because a JSON number is a binary float to most parsers, and a float cannot hold most decimal fractions exactly (RFC 7493 §2.2). Tenvelo computes with a percentage as an integer in parts per million ("8.875" is 88750), which every four-place percentage is exactly. A client that computes with one should read the digits the same way, never through a float.

      pattern ^(100(\.0{1,4})?|(0|[1-9][0-9]?)(\.[0-9]{1,4})?)$

    • schedulearray of DaypartWindow

      Replaces the whole schedule. An empty array makes the discount always-on, and so stops it applying itself.

      ≤ 8 items

      • daysarray of integer

        The weekdays this window opens on, 1 (Monday) through 7 (Sunday). An empty or absent list is every day.

        ≤ 7 items

      • end_minuteinteger

        Required

        1–1440

      • start_minuteinteger

        Required

        0–1439

    • scopestring · enum

      line_itemorder

    • statusstring · enum

      activeinactive

    • thresholdMoney

      The subtotal a spend reward starts at, in the merchant's currency; the server sets the currency.

      • amountinteger · int64

        Required

        The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

      • currencyCurrency

        Required

        ISO 4217 currency code.

        Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

        One of 182 values; see the schema.

    Responses

    • 200

      The changed discount.

      Discount · application/json
      • idstring · uuid

        Required

      • kindstring · enum

        Required

        The arithmetic. percentage takes percentage off the gross; fixed takes the amount off; spend is a spend-and-save reward (retail promotions) — when the order's subtotal reaches threshold, the percentage percentage or the amount comes off the whole order, applied by the till with no staff action. Exactly one of the two value fields is meaningful, and which one is decided here rather than inferred from whichever is set. A spend discount must be order scope.

        percentagefixedspend

      • namestring

        Required

        What staff see in the discount picker, and what the receipt prints.

        1–128 chars

      • amountMoney

        The amount off for a fixed discount, in the merchant's currency. Required when kind is fixed and ignored otherwise; the currency is the merchant's and is set by the server, so a fixed discount cannot be created in a currency the merchant does not trade in.

        • amountinteger · int64

          Required

          The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

        • currencyCurrency

          Required

          ISO 4217 currency code.

          Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

          One of 182 values; see the schema.

      • created_atTimestamp

        RequiredRead only

        Server-assigned RFC 3339 timestamp, always UTC.

        Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in client_created_at.

      • merchant_idstring · uuid

        RequiredRead only

      • percentagePercentage

        A share of a whole, from "0" to "100", as a percentage in a decimal string with at most four decimal places, as Square's percentage and Stripe Tax's percentage_decimal are: UK VAT at 20% is "20", and an 8.875% US sales tax is "8.875".

        A string because a JSON number is a binary float to most parsers, and a float cannot hold most decimal fractions exactly (RFC 7493 §2.2). Tenvelo computes with a percentage as an integer in parts per million ("8.875" is 88750), which every four-place percentage is exactly. A client that computes with one should read the digits the same way, never through a float.

        pattern ^(100(\.0{1,4})?|(0|[1-9][0-9]?)(\.[0-9]{1,4})?)$

      • schedulearray of DaypartWindow

        The windows the discount is offered in. Empty or absent is an always-on discount, offered whenever staff pick it. A scheduled discount is the only kind that applies itself, with no staff action, which is why a manual staff rate must be left unscheduled.

        ≤ 8 items

        • daysarray of integer

          The weekdays this window opens on, 1 (Monday) through 7 (Sunday). An empty or absent list is every day.

          ≤ 7 items

        • end_minuteinteger

          Required

          1–1440

        • start_minuteinteger

          Required

          0–1439

      • scopestring · enum

        Required

        Whether the discount comes off one line (line_item) or the whole order (order). A till offers a line discount on the line it is on and an order discount at the total. Required rather than defaulted: the two are offered in different places on the till, so a discount that did not say which would be offered in the wrong one.

        line_itemorder

      • statusstring · enum

        Required

        inactive pauses a discount without losing it — a seasonal promotion is deactivated rather than deleted, so it can be brought back without being recreated. Devices are sent both, and offer only the active ones.

        activeinactive

      • thresholdMoney

        The subtotal a spend reward starts at, in the merchant's currency. Required when kind is spend and ignored otherwise; a basket below it earns nothing.

        • amountinteger · int64

          Required

          The amount, as a whole number of the currency's minor unit. May be negative to express a refund, discount or credit.

        • currencyCurrency

          Required

          ISO 4217 currency code.

          Determines the minor unit that Money.amount is expressed in. Zero-decimal currencies such as JPY have a minor unit equal to the major unit.

          One of 182 values; see the schema.

      • updated_atTimestamp

        RequiredRead only

        Server-assigned RFC 3339 timestamp, always UTC.

        Server-assigned because device clocks drift and cannot be trusted for ordering — a till that has been powered off for a week may come back with a clock hours out. Where the device's own view of time matters it is carried separately in client_created_at.

      • versionVersion

        RequiredRead only

        Optimistic concurrency version, raised by the server on every write. A version is never lower than one already returned for the resource, though it may rise by more than one.

        Returned in the ETag response header as well as in the body. Send it back in If-Match on an update and the write is rejected with 412 if anyone else has written since — which is what stops two tills silently overwriting each other's edit to the same product when both come back online at once.

        ≥ 1

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 404

      No such resource, or none visible to this caller.

      Also returned instead of 403 where acknowledging existence would itself leak information — so a 404 does not prove the id is unused globally, only that it is not yours.

    • 409

      The request conflicts with current state — a uniqueness constraint, or an attempt to mutate an order that is already completed or voided.

      Unlike 412, re-reading and retrying unchanged will not resolve it; the request itself has to change.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 412

      The If-Match version did not match the resource's current version: someone else wrote since you read.

      This is the expected, healthy outcome of two clients editing the same record — not an error condition to suppress. Re-read the resource, re-apply your change on top of the new state, and retry with the fresh ETag.

    • 415

      The request Content-Type is not supported. This API accepts application/json on all mutations.

    • 422

      The request parsed but failed validation.

      invalid_params lists every failing field with a JSON Pointer, not just the first, so a client can mark up a whole form in one pass rather than making the user fix errors one round trip at a time.

      Also returned when an Idempotency-Key is replayed with a different body, and when device-computed order totals disagree with the server's recomputation.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.

  5. DELETE/v1/discounts/{id}

    Withdraw a discount

    No. deleteDiscountAuth catalog:write

    Withdraws a discount, so it is no longer offered anywhere and a tombstone reaches every device on the change feed.

    Prefer status: inactive for a promotion that will come back — a withdrawn discount cannot be reactivated. Sales already made keep the discount they were sold under, which is snapshotted onto the line, so withdrawing one never changes a receipt or a report.

    Parameters

    • idpath · string · uuid

      Required

      UUIDv7 of the resource.

      For resources a device can originate — orders, and their line items — this id is minted by the client before the record is ever synced, which is what makes an offline create idempotent.

    • If-Matchheader · string

      Required

      The ETag from the read that produced the value you are updating.

      Required on updates, not optional, because "last write wins" is the wrong default for a catalogue two managers might edit at once. If anything has been written since your read, the request is rejected with 412 and you re-read and re-apply.

      Use * to mean "write regardless of current version" — an explicit, greppable override rather than the silent default.

    Responses

    • 204

      The discount was withdrawn.

    • 400

      The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an API-Version later than the server speaks or not a date (api-version-unknown, with the server's own in api_version).

      Distinct from 422: a 400 never reached validation, so invalid_params is absent. Retrying without changing the request will fail again, except that a request refused for its API-Version goes once the server is updated.

    • 401

      No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.

      Refresh the token and retry once. Repeated 401 after a refresh means the device needs to re-enrol.

    • 403

      Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.

      Cross-tenant access is reported as 403 on a resource the caller may not see, and as 404 where even confirming existence would leak information. Retrying will not help.

    • 404

      No such resource, or none visible to this caller.

      Also returned instead of 403 where acknowledging existence would itself leak information — so a 404 does not prove the id is unused globally, only that it is not yours.

    • 409

      The request conflicts with current state — a uniqueness constraint, or an attempt to mutate an order that is already completed or voided.

      Unlike 412, re-reading and retrying unchanged will not resolve it; the request itself has to change.

    • 410

      The request names an API-Version this server no longer serves (docs/decisions/0011). Nothing in it was read. api_version names the version the server speaks: the client is behind, and sends the request again once it is updated.

    • 412

      The If-Match version did not match the resource's current version: someone else wrote since you read.

      This is the expected, healthy outcome of two clients editing the same record — not an error condition to suppress. Re-read the resource, re-apply your change on top of the new state, and retry with the fresh ETag.

    • 429

      Rate limit exceeded. Honour Retry-After rather than applying your own backoff.

      A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per (client, merchant) for everything else; no RateLimit field is sent, so this response and its Retry-After are the whole of the contract.

    • 500

      Something failed on our side. trace_id identifies the request in our tracing — quote it in a support ticket.

      The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same Idempotency-Key.

    • 503

      Temporarily unable to serve the request — a dependency is down or the service is shedding load.

      Retry with the same Idempotency-Key after Retry-After. This is always safe: a request that did take effect before the failure will be recognised as a replay.