For AI agents: markdown of this page — /docs-content-en/cowork/coupon-preview.md documentation index — /llms.txt
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
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
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 |
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:
{
"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:
{
"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:
{
"success": true,
"data": {
"valid": false,
"reason": "COUPON_INVALID",
"grant": null
}
}
Error response example
400 — a body with no code field:
{
"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 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.