Grant Pass
POST /v1/nextexams/passes/grants
Grant a pass
Gives a user a plan, from starts_at (default now) for the plan's duration.
Idempotent on source_ref: a second call with the same reference answers
the first pass with 200 instead of granting again. The user must already
exist (404 USER_NOT_FOUND); creating accounts is the caller's job. The
source is recorded as api. Each exam's own attempt limit still applies.
Scope passes:write; the role needs passes.grant.
Request
POST https://your-site.example/api/index.php/v1/nextexams/passes/grants
Request body
Content type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
integer |
No | (min 1) |
email |
string |
No | (email) |
plan_id |
integer |
Yes | (min 1) |
starts_at |
string |
No | When the pass starts, ISO 8601 (UTC unless an offset is given). Default now. |
source_ref |
string |
No | Your own reference (an order line). A retry with the same reference answers the first pass. (max length 191) |
{
"email": "[email protected]",
"plan_id": 3,
"source_ref": "order-1042-line-1"
}
Responses
200 — This reference was already used; the pass it granted.
{
"data": {
"id": 1,
"user_id": 1,
"user_name": "string",
"user_email": "string",
"plan_id": 1,
"plan_title": "string",
"source": "string",
"source_ref": "string",
"starts_at": "2026-01-01T12:00:00Z",
"expires_at": "2026-01-01T12:00:00Z",
"status": "active",
"created": "2026-01-01T12:00:00Z"
}
}
| Field | Type | Meaning |
|---|---|---|
data.id |
integer |
|
data.user_id |
integer |
|
data.user_name |
string|null |
|
data.user_email |
string|null |
|
data.plan_id |
integer |
|
data.plan_title |
string|null |
|
data.source |
string |
manual, api, or ext:<element> for a pass an extension granted. |
data.source_ref |
string|null |
The caller's own reference, unique per source. |
data.starts_at |
string|null |
ISO 8601 in UTC, or null when not set. (date-time) |
data.expires_at |
string|null |
ISO 8601 in UTC, or null when not set. (date-time) |
data.status |
string |
One of active, expired, revoked. |
data.created |
string|null |
ISO 8601 in UTC, or null when not set. (date-time) |
201 — Granted.
{
"data": {
"id": 1,
"user_id": 1,
"user_name": "string",
"user_email": "string",
"plan_id": 1,
"plan_title": "string",
"source": "string",
"source_ref": "string",
"starts_at": "2026-01-01T12:00:00Z",
"expires_at": "2026-01-01T12:00:00Z",
"status": "active",
"created": "2026-01-01T12:00:00Z"
}
}
| Field | Type | Meaning |
|---|---|---|
data.id |
integer |
|
data.user_id |
integer |
|
data.user_name |
string|null |
|
data.user_email |
string|null |
|
data.plan_id |
integer |
|
data.plan_title |
string|null |
|
data.source |
string |
manual, api, or ext:<element> for a pass an extension granted. |
data.source_ref |
string|null |
The caller's own reference, unique per source. |
data.starts_at |
string|null |
ISO 8601 in UTC, or null when not set. (date-time) |
data.expires_at |
string|null |
ISO 8601 in UTC, or null when not set. (date-time) |
data.status |
string |
One of active, expired, revoked. |
data.created |
string|null |
ISO 8601 in UTC, or null when not set. (date-time) |
Errors
| Status | Code | When |
|---|---|---|
401 |
INVALID_TOKEN |
No token (MISSING_TOKEN), or one that is wrong, expired, revoked, or whose owner can no longer sign in (INVALID_TOKEN). |
403 |
MISSING_SCOPE |
The token is valid but this request is not allowed, and code says why: MISSING_SCOPE (the token lacks the scope), FORBIDDEN (the owner's role does not allow it), ACCESS_DENIED (the owner cannot manage Next Exams), API_DISABLED (the API Tokens app is off), APP_DISABLED (the feature's app is off — meta.app names it), ACCOUNT_UNUSABLE (the owner is blocked or must reset their password), DEMO_LOCKED (a demo site). |
404 |
USER_NOT_FOUND (no account has this id or email) or PLAN_NOT_FOUND. |
|
409 |
The save or delete would break something that depends on the record: QUESTION_IN_USE, ANSWERED_NEEDS_VERSION, TYPE_LOCKED, STALE. meta carries the details (for example the exams that use a question). |
|
422 |
The request is well formed but would lose data (OPTIONS_REQUIRED). |
Examples
curl
curl -s -X POST "https://your-site.example/api/index.php/v1/nextexams/passes/grants" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","plan_id":3,"source_ref":"order-1042-line-1"}'
PHP
<?php
$ch = curl_init('https://your-site.example/api/index.php/v1/nextexams/passes/grants');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{"email":"[email protected]","plan_id":3,"source_ref":"order-1042-line-1"}',
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
JavaScript
const API_TOKEN = process.env.API_TOKEN;
const response = await fetch("https://your-site.example/api/index.php/v1/nextexams/passes/grants", {
method: 'POST',
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"email": "[email protected]",
"plan_id": 3,
"source_ref": "order-1042-line-1"
}),
});
const data = await response.json();