# Cards - 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/cards
> Every group: https://x-radius.com/llms.txt

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

### Redeem a card over the counter

`POST /api/v1/admin/cards/redeem-otc`

The same operation as the subscriber-facing redeem, with one difference: the acting manager is recorded as the reseller, so the redemption snapshots what they paid against what they charged. Without that, per-reseller pricing never resolves and every redemption records face value on both sides.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_verify` (Verify / redeem cards)
- Risk: write
- Rate limit bucket: `card_redeem`
- Idempotent on `request_id`: retrying with the same id returns the original result

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `code` | body | string | yes | The plaintext card code. |
| `user_id` | body | integer | yes | The subscriber to credit. |
| `request_id` | body | string | yes | Idempotency key. |

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_NOT_FOUND` | 404 | unknown code, or a card or subscriber outside the caller's authority |
| `ERR_CONFLICT` | 409 | already redeemed, or a request_id bound to another card |


### Top up own wallet online

`POST /api/v1/admin/cards/redeem-to-wallet`

- Authentication: manager session (JWT) or API token
- Permission: `prm_managers_self_deposit` (Top up own wallet online)
- Risk: danger
- Rate limit bucket: `card_wallet_redeem`

_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/admin/cards/redemptions/{id}`

- 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/admin/cards/redemptions/{id}/reversals`

- 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._


### Refund last activation

`POST /api/v1/admin/cards/redemptions/{id}/reversals`

- Authentication: manager session (JWT) or API token
- Permission: `prm_users_refund` (Refund last activation)
- 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._


### Redeem a card onto a subscriber

`POST /api/v1/cards/redeem`

Spends a card and applies its effect to the named subscriber. Two authority checks run, both derived from your credential and neither from the body: the card must be in your subtree, and the subscriber must be one you may write to. Both denials are 404s identical to "no such card" and "no such subscriber".

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_verify` (Verify / redeem cards)
- Risk: write
- Rate limit bucket: `card_redeem`
- Idempotent on `request_id`: retrying with the same id returns the original result

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `code` | body | string | yes | The plaintext card code. |
| `user_id` | body | integer | yes | The subscriber to credit. |
| `request_id` | body | string | yes | Idempotency key. |

#### Request

```json
{
  "code": "K7P4M2Q9XD",
  "user_id": 4711,
  "request_id": "3a1d0c77-8b52-4e19-9d64-1f7e2c5a9b30"
}
```

#### Response — 200 OK

```json
{
  "data": {
    "card_id": 90211,
    "mode": "refill_balance",
    "user_id": 4711,
    "effect_applied": { "type": "add_balance", "amount": 150.00 },
    "new_balance": 400.00
  }
}
```

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_VALIDATION` | 400 | code, user_id or request_id missing, or a reserved or over-long request_id |
| `ERR_NOT_FOUND` | 404 | unknown code, a card outside your subtree, or a subscriber you may not write to |
| `ERR_CONFLICT` | 409 | the card is already redeemed, or the request_id belongs to a redemption of a different card |
| `ERR_CONFLICT` | 410 | the card has been revoked or has expired |

#### Note

A replay returns 200 with the same card_id, mode and effect_applied, and NO replay flag — but new_balance is ABSENT, because the replay path returns the stored effect without re-reading the wallet. A missing new_balance is not a failure and is not a reason to retry. Read the balance off the subscriber record if you need it.


### POST /api/v1/cards/redeem-create

`POST /api/v1/cards/redeem-create`

- Authentication: none
- Rate limit bucket: `card_redeem`

_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._


### Verify one card by code, serial or username

`GET /api/v1/cards/search`

The counter-staff lookup. Finds a single card and reports its state, its owning reseller and who spent it. Confined to the caller's manager subtree unless they are an administrator.

- Authentication: manager session (JWT) or API token
- Permission: `prm_cards_verify` (Verify / redeem cards)
- Risk: write
- Rate limit bucket: `card_search`

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `kind` | query | enum | yes | code, serial or username. |
| `keyword` | query | string | yes | The value to match. A code is normalised first, so separators and mixed case still resolve. |

#### Response — 200 OK

```json
{
  "data": {
    "card_id": 90211,
    "serial": "000312",
    "state": "consumed",
    "effective_state": "consumed",
    "disposition": "",
    "mode": "voucher_account",
    "plan_name": "Home 20M",
    "batch_id": 208,
    "face_value": 150.00,
    "redemptions_left": 0,
    "owner_manager_id": 41,
    "owner_username": "cairo-reseller",
    "used_by_kind": "user",
    "used_by_user_id": 4711,
    "used_by_username": "ahmed",
    "used_channel": "customer_portal",
    "first_use_at": "2026-09-14 18:22:10",
    "expires_at": "2027-01-01 00:00:00"
  }
}
```

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_NOT_FOUND` | 404 | no card matches, or the card is outside the caller's subtree |

#### Note

A card outside your subtree answers the same 404 as an unknown keyword, never a 403 — a 403 would confirm the code exists and turn this into a code-guessing oracle. It carries its own per-operator budget for the same reason, separate from the tenant's.


### Revoke one card

`POST /api/v1/cards/{id}/revoke`

Flips a single card to revoked. A revoked card can never be redeemed, and the state is terminal.

- 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 | Card id. |

#### Errors

| Code | Status | When |
| --- | --- | --- |
| `ERR_NOT_FOUND` | 404 | no such card, or one outside the caller's subtree |
| `ERR_CONFLICT` | 409 | the card is already in a terminal state |


### One card's full lifecycle

`GET /api/v1/cards/{id}/timeline`

Header facts plus the chronological event list for a single card: when it was minted, printed, sold, transferred and redeemed.

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

#### Parameters

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

#### Errors

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

#### Note

This endpoint's rate-limit bucket counts DENIED lookups too, on purpose. The limiter sits ahead of the visibility check, so a walk of the card id space burns budget rather than running unmetered behind a 404.


