
## Check a coupon

`POST /v1/cowork/coupon/preview`

Checks a Cowork/Code coupon and shows the tier and term it grants. The preview reads the user's seat without changing it, and the coupon remains unredeemed.

The user and the Bitrix24 account come from the key binding; there are no path parameters.

## Request fields (body)

| Field | Type | Required | Description |
|------|-----|:-----:|----------|
| `code` | string | yes | A coupon in the form `PREFIX-BODY-CHECKSUM`, from 1 to 64 characters. We normalize case, spaces, and dash variants to a canonical form. The body accepts no other fields: any extra field, including `action`, returns `400 INVALID_CODE` |

## Examples

The endpoint supports only one authorization method: a Cowork/Code desktop key. A personal key or application key receives `403 INSUFFICIENT_SCOPE` or `403 COWORK_DESKTOP_KEY_REQUIRED`.

### curl — Cowork/Code desktop key

```bash
curl -X POST https://vibecode.bitrix24.com/v1/cowork/coupon/preview \
  -H "X-Api-Key: YOUR_COWORK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "PRAKTIKUM-7HKM4TQZ2RVW-45C80C"}'
```

### JavaScript — Cowork/Code desktop key

```javascript
const res = await fetch('https://vibecode.bitrix24.com/v1/cowork/coupon/preview', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_COWORK_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ code: 'PRAKTIKUM-7HKM4TQZ2RVW-45C80C' }),
})

if (!res.ok) {
  const { error } = await res.json()
  console.error(error.code)
} else {
  const { data } = await res.json()

  if (!data.valid) {
    // Select the user-facing text based on data.reason — see "Refusal reasons".
    console.log(data.reason)
  } else {
    console.log(`${data.grant.tier} for ${data.grant.termMonths} months`)
    console.log('What you can do:', data.decision.kind)
  }
}
```

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `success` | boolean | `true` when the request is processed successfully. A coupon refusal also returns `true`; its outcome is reported in `data.valid` |
| `data.valid` | boolean | `true` when the coupon is active and the campaign is open |
| `data.reason` | string or null | The refusal reason when `valid: false` — one of the values listed under "Refusal reasons". It is `null` when `valid: true` |
| `data.grant` | object or null | What the coupon grants. It is `null` when `valid: false` |
| `data.grant.tier` | string | The granted tier: `PRO`, `MAX` or `ULTRA` |
| `data.grant.termMonths` | number | The grant term in months |
| `data.grant.campaignName` | string | The campaign name — show it to the user in the confirmation dialog |
| `data.decision` | object | What will happen to the user's seat and which choices can be offered. The field is always present when `valid: true` and omitted entirely when `valid: false` — check it with `data.decision?.kind`, not by comparing it with `null` |
| `data.decision.kind` | string | The decision type: `apply`, `extend`, `choose` or `refuse`. For `apply` and `extend`, send the same value in the `action` field when redeeming the code. For `choose`, take the action from `options`. Never send `choose` or `refuse` in `action` |
| `data.decision.addsMonths` | number | Only for `extend`: the number of months added to the current tier's term |
| `data.decision.reason` | string | Only for `refuse`: the refusal code used to select the user-facing text |
| `data.decision.facts` | object | Only for `choose`: details about the seat that the dialog must show before the user chooses |
| `data.decision.options` | array | Only for `choose`: the available actions. Each element contains `kind`; send that value in the `action` field when redeeming the code |

### Decision types

The `data.decision` field describes what happens to the user's seat if the coupon is redeemed now.

| `kind` | When it is returned | What to show |
|--------|----------------|--------------|
| `apply` | The seat is vacant, on the free tier, closed, or does not exist yet | The gift confirmation and a "Yes, activate" button |
| `extend` | The gifted tier matches the current one, the seat is paid for no more than a month, and the campaign allows extending the term | "The tier will be extended by `addsMonths` months." The tier does not change; only its term is extended |
| `choose` | The seat is taken, but the user has a choice | Show the facts from `facts` first, followed by the buttons for `options` |
| `refuse` | There is nothing to offer | A refusal. Select the text using the code in `reason`: one of the `COUPON_SEAT_*` codes or `COUPON_TIER_DOWNGRADE_BLOCKED`, described in the table on [Redeem a coupon](/docs/cowork/coupon-redeem) |

The contents of `facts` in the `choose` decision:

| Field | Type | Description |
|------|-----|----------|
| `facts.currentTier` | string | The current tier of the seat |
| `facts.paidThroughAt` | string | The date through which the seat is paid, in ISO 8601 format |
| `facts.isPaid` | boolean | The seat is actively billed |
| `facts.cancellationScheduled` | boolean | A cancellation has been scheduled for the seat |
| `facts.grantTier` | string | The gifted tier |
| `facts.grantTermMonths` | number | The gifted term in months |

The contents of an `options` element:

| Field | Type | Description |
|------|-----|----------|
| `options[].kind` | string | The action: `force` — apply the gift now instead of the current tier; `resume-and-apply` — undo the scheduled cancellation and apply the gift |
| `options[].losesDays` | number | Only for `force`: the number of whole paid days that will be forfeited. Use the returned value; do not recalculate it on the client |
| `options[].losesTier` | string | Only for `force`: the tier whose paid term will be forfeited |

### Refusal reasons

The values of the `data.reason` field when `valid: false`.

| Reason | What to show the user |
|---------|---------------------------|
| `COUPON_INVALID` | "This code cannot be used". This value covers "the code does not exist", "the code was revoked", "the code was already redeemed", "the code expired", "the campaign ended", "the per-user limit is exhausted", and "the per-account limit is exhausted" |
| `COUPON_TOO_MANY_ATTEMPTS` | "Too many attempts. Try again in half an hour" |
| `COUPON_ALREADY_REDEEMED_BY_YOU` | "You have already activated this coupon". Do not show "this code cannot be used" here — the user has already received the gift |
| `COUPON_NOT_ASSIGNED_TO_YOU` | "The coupon was issued to a different email address. Sign in with that address and try again". The coupon is active, and the user can resolve this themselves |
| `COUPON_PORTAL_ACCESS_GATED` | "Cowork/Code access is disabled for this account — contact your administrator" |
| `COUPON_TARIFF_NOT_ELIGIBLE` | "The coupon does not apply to this Bitrix24 plan". The campaign is limited to a list of plans, and the account plan is not on it. The refusal is terminal: the coupon will not start working on this account by itself, so there is no point in repeating the call |
| `COUPON_TARIFF_UNKNOWN` | "The account plan is not known yet. Try again later". The campaign is limited to a list of plans, and the platform has not read the account plan yet — this happens on a freshly connected account. The coupon is intact and the attempt is not counted against the limit. Do not show "this code cannot be used" here: the right action is to retry later |

## Response example

The coupon is valid and the user's seat is vacant:

```json
{
  "success": true,
  "data": {
    "valid": true,
    "reason": null,
    "grant": {
      "tier": "PRO",
      "termMonths": 3,
      "campaignName": "Partner workshop, August"
    },
    "decision": { "kind": "apply" }
  }
}
```

The coupon is valid, but the seat is taken by a higher paid tier — the user chooses:

```json
{
  "success": true,
  "data": {
    "valid": true,
    "reason": null,
    "grant": {
      "tier": "PRO",
      "termMonths": 3,
      "campaignName": "Partner workshop, August"
    },
    "decision": {
      "kind": "choose",
      "facts": {
        "currentTier": "MAX",
        "paidThroughAt": "2026-09-14T10:00:00.000Z",
        "isPaid": true,
        "cancellationScheduled": false,
        "grantTier": "PRO",
        "grantTermMonths": 3
      },
      "options": [
        { "kind": "force", "losesDays": 24, "losesTier": "MAX" }
      ]
    }
  }
}
```

The coupon is invalid, but the endpoint still returns `200`, and the body has no `decision` field:

```json
{
  "success": true,
  "data": {
    "valid": false,
    "reason": "COUPON_INVALID",
    "grant": null
  }
}
```

## Error response example

400 — a body with no `code` field:

```json
{
  "success": false,
  "error": {
    "code": "INVALID_CODE",
    "message": "Field `code` is required"
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 400 | `INVALID_CODE` | The body has no string `code` field, the value is empty or longer than 64 characters, or the body contains an unsupported field. Only the redemption endpoint accepts `action`; including it here returns the same error code |
| 400 | `INVALID_JSON_BODY` | A body was sent, but it does not parse as JSON |
| 401 | `MISSING_API_KEY` | The `X-Api-Key` header was not provided |
| 401 | `INVALID_API_KEY` | Invalid authorization key |
| 403 | `INSUFFICIENT_SCOPE` | The key has no `vibe:cowork` scope |
| 403 | `COWORK_DESKTOP_KEY_REQUIRED` | The caller used another key type: a personal key, an application key, an agent seat key, or a project deployment key |
| 403 | `COUPON_FEATURE_DISABLED` | Coupons are not enabled on the account. Hide the input field entirely |
| 403 | `KEY_NOT_BOUND_TO_USER` | The key is not bound to an account user |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | The key was issued as read-only |
| 429 | `RATE_LIMITED` | The platform-wide limit is 20 requests per minute. The effective limit for your key is returned in the `x-ratelimit-limit` header. It is lower because the platform-wide limit is divided across replicas |

See [Errors](/docs/errors) for the full list of common API errors.

A refusal of the coupon itself is not an error; it is returned with status `200` and `data.valid: false`.

## Known specifics

**The preview does not answer the question "will redemption succeed?"** The `data.valid` field checks only the coupon and campaign: the code is active, it has not expired, the campaign is open, and neither the per-user nor the per-account limit is exhausted. The separate `data.decision` field covers the seat conditions, but it is not a guarantee either: the campaign's overall redemption cap is checked only during redemption, and the seat may change between the two calls. Prepare a refusal screen for the redemption step even after a successful preview.

**The preview reads the seat but does not create one.** Redemption creates a free seat for the user before checking the rules; the preview does not, because previewing a coupon must not change state. For a user without a seat, the decision therefore resolves to `apply` rather than an empty value.

**Failed attempts count against the same counter as redemption.** The count is keyed by the account–user pair, so one person's attempts do not affect their colleagues on the account. After ten failures within an hour, further attempts are blocked for thirty minutes. A successful redemption resets the counter.

**A typo does not consume an attempt.** The final segment of the coupon is a checksum over the prefix and body. Input that fails the checksum never reaches the database, so copying a code from a handout or reading it over the phone does not consume an attempt.

**Do not call the preview endpoint on every keystroke.** Because preview is cheaper and repeatable, it poses a greater brute-force risk than redemption. Bind the call to a button or use a delay of at least 500 ms after the last keystroke.

**Seven different refusals are deliberately collapsed into `COUPON_INVALID`; clients do not need to distinguish them.** A separate reason for each case would tell someone guessing codes which prefixes are active: receiving a "limit is exhausted" response would prove that the submitted code belongs to a real campaign. Do not add branches for "the code was revoked" or "the limit is exhausted" — those values never appear in the response.

**A refusal takes at least four hundred milliseconds.** The response is padded to a minimum duration so that timing cannot distinguish "there is no such code" from "the code exists but was revoked". A response that naturally takes longer is not shortened.

**Deferred application is not supported.** The action "apply the gift at the end of the paid term" never appears in `options`. If such an option appears, it indicates a bug on our side, not a signal to render a button for it.

## See also

- [Redeem a coupon](/docs/cowork/coupon-redeem)
- [Cowork/Code subscription state](/docs/cowork/state)
- [Cowork/Code subscription summary](/docs/cowork/me)
- [Tier change preview](/docs/cowork/subscription-preview)
- [Cowork/Code](/docs/cowork)
- [Errors](/docs/errors)
