# Card Batches - X-Radius API

> Base URL: https://x-radius.com/api/v1
> Auth: Authorization: Bearer xrt_...  (a manager API token)
> Envelope: {"data": ...}; lists add {"meta":{page,page_size,total,has_next}}
> Errors: {"error":{"code","message","request_id"}} - branch on code, never on message
> Timestamps: yyyy-MM-dd HH:mm:ss, UTC
> Money: a bare JSON number in major units, with an ISO-4217 currency code beside it
> Idempotency: redeem and activate endpoints take a client-supplied request_id (UUID)
>
> This page: https://x-radius.com/docs/api/card-batches
> Every group: https://x-radius.com/llms.txt

28 endpoints in 1 resource groups. 12 carry a hand-written reference entry with examples; the remaining 16 are generated from the running router and carry method, path, authentication, permission and rate-limit bucket, but no request or response example.

### List card series

`GET /api/v1/card-batches`

One page of card series with their stock breakdown. A non-admin caller sees only series created within their own manager subtree.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_list` (View card batches & cards)
- Risk: read

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `filter[status]` | query | string | no | Generation status — generating, ready, failed. |
| `filter[card_mode]` | query | string | no | The series mode. |
| `filter[owner_manager_id]` | query | integer | no | The reseller holding the series after any transfer. |
| `filter[suspended]` | query | boolean | no | Series whose unused cards have been revoked. |
| `filter[template_id]` | query | integer | no | Series cut from one saved card template. |
| `q` | query | string | no | Plan name, distribution note, or series id. |

#### Response — 200 OK

```json
{
  "data": [
    {
      "id": 208,
      "plan_id": 7,
      "plan_name": "Home 20M",
      "card_mode": "voucher_account",
      "quantity": 500,
      "face_value": 150.00,
      "code_length": 10,
      "code_charset": "alnum_no_ambig",
      "status": "ready",
      "suspended": false,
      "processed": 500,
      "used": 312,
      "available": 171,
      "sold_online": 9,
      "sold_counter": 8,
      "stuck_sale": 0,
      "expiration": "2027-01-01 00:00:00",
      "printed_at": "2026-08-30 12:01:44",
      "print_count": 1,
      "pdf_ready": true,
      "has_cards": true,
      "owner_manager_id": 41,
      "sell_online": false,
      "auto_activate": false,
      "created_by": 41
    }
  ],
  "meta": { "page": 1, "page_size": 50, "total": 24, "has_next": false }
}
```

#### Note

Never derive stock by subtraction. used excludes both unused and revoked, so quantity minus used is not the unused count, and quantity itself grows when the point of sale mints into the series. available is the sellable stock. stuck_sale is a warning, not a bucket: it counts cards held by a till sale that never completed, and there is no reaper for it.


### Generate a card series

`POST /api/v1/card-batches`

Stages an asynchronous generation job and returns immediately. The cards and the printable artifact are produced in the background; poll the series until status is ready, then fetch the print job.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_generate_user` (Generate user cards)
- Risk: write
- Rate limit bucket: `t_mutate`
- Idempotent on `request_id`: retrying with the same id returns the original result

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `type` | body | enum | yes | refill or prepaid. |
| `quantity` | body | integer | yes | How many cards to mint. |
| `face_value` | body | number | yes | Value per card, major units. |
| `expiration` | body | string | yes | yyyy-MM-dd HH:mm:ss or yyyy-MM-dd. Series-level card lifetime. |
| `plan_profile_id` | body | integer | no | Required for prepaid, and for a refill card that auto-activates. |
| `code_length` | body | integer | no | The redemption PIN length. A MINIMUM, not an exact value — a mint may exceed it when the tenant's live code pool needs more entropy. |
| `code_charset` | body | enum | no | numeric, alpha, alnum or alnum_no_ambig. |
| `owner_manager_id` | body | integer | no | The series owner. Defaults to the creator. |
| `request_id` | body | string | yes | Idempotency key. |

#### Request

```json
{
  "type": "prepaid",
  "quantity": 500,
  "face_value": 150.00,
  "expiration": "2027-01-01",
  "plan_profile_id": 7,
  "code_length": 10,
  "code_charset": "alnum_no_ambig",
  "request_id": "5f2a7c18-2b0d-4a61-9f3e-7a4c8d2e1b09"
}
```

#### Response — 202 Accepted

```json
{
  "data": {
    "batch_id": 209,
    "mode": "voucher_account",
    "status": "generating",
    "replay": false
  }
}
```

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_VALIDATION` | 400 | bad expiration, an unworkable spec, or a request_id that is missing, too long or reserved |
| `ERR_VALIDATION` | 402 | the generating manager's wallet cannot cover the generation charge |
| `ERR_FORBIDDEN` | 403 | minting refill cards without the refill-generation permission, which is separate from the generate permission the route checks |

#### Note

A fresh stage answers 202 Accepted; a replay of the same request_id answers 200 OK with the original batch_id and replay true. The status field says generating in both cases, because it describes the job, not the outcome of this call. Branch on the HTTP status or on replay, not on status.


### Delete card batches

`POST /api/v1/card-batches/bulk-delete`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_delete` (Delete card batches)
- Risk: danger
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Change card expiry

`POST /api/v1/card-batches/bulk-expiry`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_change_expiry` (Change card expiry)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Download / export cards

`POST /api/v1/card-batches/bulk-print-job`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_download` (Download / export cards)
- Risk: write
- Rate limit bucket: `t_heavy`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Download / export cards

`POST /api/v1/card-batches/bulk-printed`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_download` (Download / export cards)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Suspend / release cards

`POST /api/v1/card-batches/bulk-release`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_suspend_release` (Suspend / release cards)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Suspend / release cards

`POST /api/v1/card-batches/bulk-suspend`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_suspend_release` (Suspend / release cards)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Generate user cards

`GET /api/v1/card-batches/code-length`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_generate_user` (Generate user cards)
- Risk: write

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Delete a card series

`DELETE /api/v1/card-batches/{id}`

Deletes the series and its cards. Refuses outright once any card in it has been redeemed, transferred or sold at the till, because those are financial records.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_delete` (Delete card batches)
- Risk: danger
- Rate limit bucket: `t_mutate`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_CONFLICT` | 409 | the series has redemptions, transfers, or counter sales |


### Fetch one card series

`GET /api/v1/card-batches/{id}`

The series header, its generation spec and its stock breakdown.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_list` (View card batches & cards)
- Risk: read

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_NOT_FOUND` | 404 | no such series, or one outside the caller's subtree |


### Manage card designs

`PATCH /api/v1/card-batches/{id}`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_designer` (Manage card designs)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### List the cards in a series

`GET /api/v1/card-batches/{id}/cards`

One row per card, carrying the plaintext code and, for prepaid series, the login username and password. Behind both the card-list permission and the per-series subtree gate for that reason.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_list` (View card batches & cards)
- Risk: read

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |
| `filter[state]` | query | string | no | Stored state — unused, consumed, active, exhausted, revoked. |
| `filter[disposition]` | query | string | no | Who holds the card, which is orthogonal to state. |
| `filter[has_customer]` | query | boolean | no | Cards linked to a subscriber. |

#### Response — 200 OK

```json
{
  "data": [
    {
      "id": 90211,
      "serial": "000312",
      "code": "K7P4M2Q9XD",
      "username": "v90211",
      "password": "8sk3ndq1",
      "state": "unused",
      "effective_state": "unused",
      "disposition": "available",
      "redemptions_left": 1,
      "expires_at": "2027-01-01 00:00:00",
      "used_bytes": 0,
      "used_seconds": 0
    }
  ],
  "meta": { "page": 1, "page_size": 50, "total": 500, "has_next": true }
}
```

#### Note

state and effective_state are different questions and you almost always want the second. Nothing ever writes state 'expired', so a voucher past its expiry still reads as 'unused' in state and shows as available stock; effective_state applies the expiry at read time. Meanwhile disposition answers who holds the card — a voucher sold at the till but not yet redeemed is still 'unused' and is not stock you can sell again.


### Change card expiry

`POST /api/v1/card-batches/{id}/expiry`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_change_expiry` (Change card expiry)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Download / export cards

`GET /api/v1/card-batches/{id}/pdf`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_download` (Download / export cards)
- Risk: write
- Rate limit bucket: `t_heavy`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### View card batches & cards

`GET /api/v1/card-batches/{id}/pricing`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_list` (View card batches & cards)
- Risk: read

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Manage card designs

`DELETE /api/v1/card-batches/{id}/pricing/{resellerId}`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_designer` (Manage card designs)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Manage card designs

`PUT /api/v1/card-batches/{id}/pricing/{resellerId}`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_designer` (Manage card designs)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Fetch a series' print job

`GET /api/v1/card-batches/{id}/print-job`

The data a client needs to render the printable sheet in the browser. Rendering happens client-side; the server ships no PDF engine.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_download` (Download / export cards)
- Risk: write

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |


### Mark a series printed

`POST /api/v1/card-batches/{id}/printed`

Records that the sheet was rendered, stamping printed_at and incrementing print_count.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_download` (Download / export cards)
- Risk: write
- Rate limit bucket: `t_mutate`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |

#### Note

Print tracking is observational only and deliberately does NOT move the series status. Nothing may set status to 'printed': the till refuses to mint into any series whose status is not 'ready', so auto-stamping would silently close every printed series to counter top-ups.


### Generate user cards

`POST /api/v1/card-batches/{id}/regenerate`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_generate_user` (Generate user cards)
- Risk: write
- Rate limit bucket: `t_mutate`

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### Release a suspended card series

`POST /api/v1/card-batches/{id}/release`

Puts the revoked-but-unused cards of a suspended series back into stock.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_suspend_release` (Suspend / release cards)
- Risk: write
- Rate limit bucket: `t_mutate`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |


### Suspend a card series

`POST /api/v1/card-batches/{id}/suspend`

Revokes every still-unused card in the series. Cards already in flight are left alive. Re-running it is a harmless no-op.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_suspend_release` (Suspend / release cards)
- Risk: write
- Rate limit bucket: `t_mutate`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |

#### Response — 200 OK

```json
{
  "data": { "message_key": "card_batch_suspended" }
}
```


### Transfer N cards from a series

`POST /api/v1/card-batches/{id}/transfer-count`

Like transfer-whole, but moves a count of cards off the top of the available stock rather than the entire series.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_change_owner` (Transfer / change card owner)
- Risk: danger
- Rate limit bucket: `t_mutate`
- Idempotent on `request_id`: retrying with the same id returns the original result

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |
| `count` | body | integer | yes | How many cards to move. |
| `from_manager_id` | body | integer | yes | The current holder. |
| `to_manager_id` | body | integer | yes | The receiving reseller. |
| `unit_price` | body | number | yes | Price per card. Required, never defaulted to zero. |
| `request_id` | body | string | yes | Idempotency key. |


### Transfer a card id range

`POST /api/v1/card-batches/{id}/transfer-range`

Moves a contiguous range of card ids from the series, for the case where the physical cards handed over are a known block.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_change_owner` (Transfer / change card owner)
- Risk: danger
- Rate limit bucket: `t_mutate`
- Idempotent on `request_id`: retrying with the same id returns the original result

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |
| `card_id_range` | body | object | yes | The inclusive id range to move. |
| `unit_price` | body | number | yes | Price per card. Required. |
| `request_id` | body | string | yes | Idempotency key. |


### Transfer a whole series to another reseller

`POST /api/v1/card-batches/{id}/transfer-whole`

Moves every card in the series from one manager to another at an agreed unit price, posting the matching ledger entries on both sides.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_change_owner` (Transfer / change card owner)
- Risk: danger
- Rate limit bucket: `t_mutate`
- Idempotent on `request_id`: retrying with the same id returns the original result

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | Series id. |
| `from_manager_id` | body | integer | yes | The current holder. |
| `to_manager_id` | body | integer | yes | The receiving reseller. |
| `unit_price` | body | number | yes | Price per card, major units. Must be sent explicitly — omitting it is an error, not a zero. |
| `request_id` | body | string | yes | Idempotency key. |

#### Request

```json
{
  "from_manager_id": 41,
  "to_manager_id": 57,
  "unit_price": 140.00,
  "request_id": "c8e1a4b9-7f30-4d62-b0a5-6e2c9d3f1074"
}
```

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_VALIDATION` | 400 | unit_price omitted, or a request_id that is missing, too long or reserved |
| `ERR_CONFLICT` | 409 | the request_id is already bound to a different transfer |

#### Note

unit_price is a required tri-state and omitting it is refused rather than read as zero. A bare 0 from a client that simply left the field out would mean "give the stock away", which is exactly the defect this check exists to stop. A negative price inverts the transfer.


### View card batches & cards

`GET /api/v1/card-batches/{id}/transferable-cards`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_list` (View card batches & cards)
- Risk: read

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


### View card batches & cards

`GET /api/v1/card-batches/{id}/transfers`

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_list` (View card batches & cards)
- Risk: read

_This endpoint has no hand-written reference entry yet. The method, path, authentication, permission and rate limit above are generated from the running router and are accurate; there is no request or response example._


