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

# Studio discounts and offers

> Create, revise, activate, inspect, and revoke studio-scoped discount offers.

Studio offers are owned by one studio and one API environment. The API accepts
discount terms and selectors; Batch Relay checks eligibility, calculates
discounts, provisions any payment-provider objects, and records redemptions.
All offer amounts use USD cents.

Code offers can be entered by customers in hosted Stripe Checkout when code
entry is enabled for that environment. Publishing a code through this API does
not enable the checkout field. Automatic offers are selected by the checkout
service. Creating an offer saves a draft; activate or schedule the returned
revision before expecting it to be used.

## Access and scopes

Use an account-owned API key, the exact studio ID in each path, and an active
grant for that studio. Anonymous sessions cannot manage offers. Test and Live
keys select separate offer environments; a Test Mode offer does not become a
Live offer. Use a Test Mode key to exercise offer writes in the isolated Test
environment before making a Live change.

| Operation | Required scope |
| - | - |
| List or read offers | `offers:read` |
| Create, revise, activate, schedule, pause, resume, or revoke | `offers:write` |
| Read redemption history | `offers:read` |
| Preview offer margin | `margins:read` |

Studio roles also apply: owners and admins can manage offers, while other
authorized studio staff can read them. `offers:write` does not imply
`offers:read`; give a key both scopes only when its workflow needs both.

Checkout-link creation, preparation, and recovery also require
`offers:apply`, alongside the checkout-link and print-order scopes.
See [Checkout Links](/checkout-links.md).

## Routes

All routes are under `/v1/studios/{studio_id}` and require the exact studio
grant.

| Method and path | Scope | Purpose |
| - | - | - |
| `GET /offers` | `offers:read` | List offers. |
| `POST /offers` | `offers:write` | Create a draft offer. |
| `GET /offers/{offer_id}` | `offers:read` | Read one offer. |
| `PATCH /offers/{offer_id}` | `offers:write` | Append a candidate terms revision. |
| `POST /offers/{offer_id}/activate` | `offers:write` | Activate a specific revision. |
| `POST /offers/{offer_id}/schedule` | `offers:write` | Schedule a specific revision. |
| `POST /offers/{offer_id}/pause` | `offers:write` | Pause an offer. |
| `POST /offers/{offer_id}/resume` | `offers:write` | Resume a paused offer. |
| `POST /offers/{offer_id}/revoke` | `offers:write` | Permanently revoke an offer. |
| `GET /offers/{offer_id}/redemptions` | `offers:read` | Read redemption history. |
| `POST /offers/{offer_id}/margin-preview` | `margins:read` | Preview current estimated margin. |

## Create a code

The create request accepts a complete set of terms. It does not accept Stripe
IDs, a calculated price, or a client-supplied discount amount for an order.
Code values must be uppercase, contain no whitespace, and be at most 80
characters. Percentage discounts use whole percentages from 1 through 100;
fixed discounts use a positive amount in cents. Currency must be USD.
For a fixed amount, set `kind` to `fixed` and send `amount_off_cents` instead
of `percent_off`.

```http theme={null}
POST /v1/studios/stu_example/offers
Authorization: Bearer br_test_your_test_key
Idempotency-Key: fall-2026-offer-create
Content-Type: application/json
```

```json theme={null}
{
  "name": "Fall weekend",
  "code": "FALL2026",
  "kind": "percent",
  "percent_off": 15,
  "currency": "USD",
  "application": "code",
  "scope": {
    "kind": "storewide"
  },
  "limits": {
    "max_redemptions": 50,
    "once_per_person": true
  },
  "minimum_subtotal_cents": 2500
}
```

`application` is `code` for a customer-entered code or `automatic` for a
server-selected offer. Automatic offers must omit `code`. Scope can be
`storewide`, `event`, `guardian`, `product`, or `package`; event and guardian
scopes take their matching ID, while product and package scopes take a
`sellable_ids` array. `limits.max_redemptions` is optional and must be positive
when supplied. `starts_at`, `ends_at`, and `minimum_subtotal_cents` are optional.

The `201` response contains the offer and its first immutable revision:

```json theme={null}
{
  "id": "off_example",
  "name": "Fall weekend",
  "code": "FALL2026",
  "kind": "percent",
  "percent_off": 15,
  "currency": "USD",
  "application": "code",
  "scope": {
    "kind": "storewide"
  },
  "limits": {
    "max_redemptions": 50,
    "once_per_person": true
  },
  "minimum_subtotal_cents": 2500,
  "status": "draft",
  "revision": 1,
  "revision_id": "revision_example",
  "provisioning_status": "succeeded",
  "usage": {
    "reserved": 0,
    "redeemed": 0,
    "max_uses": 50,
    "remaining": 50
  },
  "created_at": "2026-10-20T08:00:00Z",
  "updated_at": "2026-10-20T08:00:00Z"
}
```

The example response is illustrative; provisioning status and timestamps come
from the service. Store the returned `id`, `revision`, and `revision_id` for
subsequent operations.

## Revise terms, then activate that revision

Terms are immutable after creation. `PATCH` appends a candidate revision and
does not activate it. Send the full replacement terms under `terms`, the
revision number you read as `expected_revision`, and a new idempotency key for
this mutation.

```http theme={null}
PATCH /v1/studios/stu_example/offers/off_example
Authorization: Bearer br_test_your_test_key
Idempotency-Key: fall-2026-offer-revision-2
Content-Type: application/json
```

```json theme={null}
{
  "expected_revision": 1,
  "terms": {
    "name": "Fall weekend",
    "code": "FALL2026",
    "kind": "percent",
    "percent_off": 20,
    "currency": "USD",
    "application": "code",
    "scope": {
      "kind": "storewide"
    },
    "limits": {
      "max_redemptions": 50,
      "once_per_person": true
    },
    "minimum_subtotal_cents": 2500
  }
}
```

The response contains the new candidate revision, for example
`revision: 2` and `revision_id: "revision_example_2"`. Activate it explicitly:

```http theme={null}
POST /v1/studios/stu_example/offers/off_example/activate
Authorization: Bearer br_test_your_test_key
Idempotency-Key: fall-2026-offer-activate-2
Content-Type: application/json
```

```json theme={null}
{
  "expected_revision": 2,
  "revision_id": "revision_example_2"
}
```

Activation selects the named immutable revision for new checkouts. Existing
checkouts and paid orders retain their frozen revision and amounts. A stale
`expected_revision` returns `409 offer_conflict`; read the current offer and
repeat the intended change with the current revision and a new idempotency key.

## Schedule, pause, resume, or revoke

Scheduling also selects a revision. `starts_at` must be in the future, and
`ends_at`, when supplied, must be later than `starts_at`.

```http theme={null}
POST /v1/studios/stu_example/offers/off_example/schedule
Authorization: Bearer br_test_your_test_key
Idempotency-Key: fall-2026-offer-schedule
Content-Type: application/json
```

```json theme={null}
{
  "expected_revision": 2,
  "revision_id": "revision_example_2",
  "starts_at": "2030-01-01T00:00:00Z",
  "ends_at": "2030-01-08T00:00:00Z"
}
```

Pause, resume, and revoke use the same lifecycle route pattern and require
`expected_revision` plus an idempotency key. A short `reason` may be included.

To remove an offer from future use, revoke it. This action cannot be reversed:

```http theme={null}
POST /v1/studios/stu_example/offers/off_example/revoke
Authorization: Bearer br_test_your_test_key
Idempotency-Key: fall-2026-offer-revoke
Content-Type: application/json
```

```json theme={null}
{
  "expected_revision": 2,
  "reason": "Promotion is no longer offered"
}
```

```http theme={null}
POST /v1/studios/stu_example/offers/off_example/pause
Authorization: Bearer br_test_your_test_key
Idempotency-Key: fall-2026-offer-pause
Content-Type: application/json
```

```json theme={null}
{
  "expected_revision": 2,
  "reason": "End of promotion window"
}
```

Revocation is the permanent lifecycle action for an offer. It retains the offer,
its revisions, redemptions, and audit history. There is no hard-delete route.
Pause is reversible; revoke is the terminal state.

## List offers and continue a page

`GET /v1/studios/{studio_id}/offers` returns all offers visible to the studio;
the API does not take a status filter. Filter returned items by `status` when
you need a view such as active codes. For active codes, keep items where
`status` is `active` and `application` is `code`. The default page size is 50. Set `limit`
from 1 through 100 and pass `next_cursor` back as the opaque `cursor` query
parameter until `is_done` is `true`.

```http theme={null}
GET /v1/studios/stu_example/offers?limit=100
Authorization: Bearer br_test_your_test_key
```

```json theme={null}
{
  "items": [
    {
      "id": "off_example",
      "name": "Fall weekend",
      "code": "FALL2026",
      "kind": "percent",
      "percent_off": 20,
      "currency": "USD",
      "application": "code",
      "scope": {
        "kind": "storewide"
      },
      "limits": {
        "max_redemptions": 50,
        "once_per_person": true
      },
      "minimum_subtotal_cents": 2500,
      "status": "active",
      "revision": 2,
      "revision_id": "revision_example_2",
      "provisioning_status": "succeeded",
      "usage": {
        "reserved": 0,
        "redeemed": 3,
        "max_uses": 50,
        "remaining": 47
      },
      "created_at": "2026-10-20T08:00:00Z",
      "updated_at": "2026-10-20T08:00:00Z"
    }
  ],
  "next_cursor": "opaque-cursor",
  "is_done": false
}
```

Request the next page as
`GET /v1/studios/stu_example/offers?limit=100&cursor=opaque-cursor`, URL
encoding the cursor value in an HTTP client. `next_cursor` is `null` when the
page is complete.

## Read redemption history

```http theme={null}
GET /v1/studios/stu_example/offers/off_example/redemptions?limit=50
Authorization: Bearer br_test_your_test_key
```

Redemption pages use the same `items`, `next_cursor`, and `is_done` envelope.
Each item includes the offer revision, order ID when available, discount cents,
currency, status, and creation time. Status is `succeeded`,
`partially_refunded`, or `refunded`. Follow every cursor to read the complete
history; a single page is not a complete usage report.

```json theme={null}
{
  "items": [
    {
      "id": "redemption_example",
      "offer_id": "off_example",
      "revision": 2,
      "order_id": "order_example",
      "discount_cents": 500,
      "currency": "USD",
      "status": "succeeded",
      "created_at": "2026-10-20T08:15:00Z"
    }
  ],
  "next_cursor": null,
  "is_done": true
}
```

## Preview margin

Margin preview uses the separate `margins:read` scope and the server's current
catalog and provider-cost evidence. It is an estimate, not an order quote.
Unknown or stale costs remain marked in `cost_status`.

```http theme={null}
POST /v1/studios/stu_example/offers/off_example/margin-preview
Authorization: Bearer br_test_your_test_key
Content-Type: application/json
```

```json theme={null}
{
  "sellable": {
    "type": "item",
    "id": "catalog_item_example",
    "quantity": 1
  }
}
```

The response identifies the revision used for the estimate, line totals, and
cost status. When cost is unknown, promotion or contribution fields may be
omitted:

```json theme={null}
{
  "offer_id": "off_example",
  "revision": 2,
  "currency": "USD",
  "lines": [
    {
      "sellable_id": "catalog_item_example",
      "quantity": 1,
      "normal_selling_cents": 2000,
      "cost_status": "unknown"
    }
  ],
  "normal_subtotal_cents": 2000,
  "cost_status": "unknown",
  "generated_at": "2026-10-20T08:15:00Z"
}
```

## Idempotency and errors

Every create, revision, and lifecycle write requires an `Idempotency-Key` with
at most 200 characters. Keep the same key and exact request body when retrying
an uncertain result. Use a new key for a distinct intended mutation. Reusing a
key with different input or sending a stale revision returns
`409 offer_conflict`.

| Status | Error | Meaning |
| - | - | - |
| `400` | `idempotency_key_required` | A create, revision, or lifecycle write is missing its idempotency key. |
| `401` | `invalid_api_key` | The bearer key is missing, invalid, or inactive. |
| `403` | `offers_forbidden` | The key lacks the required scope or studio authorization. |
| `404` | `offer_not_found` | The offer is not available to the authorized studio. |
| `409` | `offer_conflict` | The expected revision is stale, the lifecycle transition is invalid, or the idempotency key was reused with different input. |
| `422` | `invalid_offer` | Terms, lifecycle fields, or selectors are invalid. |
| `422` | `invalid_management_page` | The cursor or page limit is invalid. |
| `503` | `offers_unavailable` | Offer management is unavailable on this deployment. |

See [Authentication](/authentication.md), [Idempotency](/idempotency.md), and
[Errors](/errors.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.