> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keebai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# POST /v1/loyalty/memberships/{id}/ledger

> Accrue, redeem, or correct points and stamps. Updates the balance and the customer's wallet card.

Records a movement against a membership. This is the endpoint your point of sale calls: it moves the balance, writes the audit entry, and pushes the new number to the card in the customer's wallet.

Whether the movement touches points or stamps is decided by the **program**, not by this call. You never pass a metric.

## Endpoint

```
POST https://api.keebai.com/v1/loyalty/memberships/{id}/ledger
```

## Required scope

`loyalty:write`

## Headers

| Header          | Required | Value                    |
| --------------- | -------- | ------------------------ |
| `Authorization` | Yes      | `Bearer kbai_pk_<token>` |
| `Content-Type`  | Yes      | `application/json`       |

## Path parameters

| Parameter | Type     | Description                |
| --------- | -------- | -------------------------- |
| `id`      | `string` | The membership `ObjectId`. |

## Body

| Field    | Type     | Required | Description                                                                                        |
| -------- | -------- | -------- | -------------------------------------------------------------------------------------------------- |
| `kind`   | `string` | Yes      | `earn`, `redeem`, or `adjust`.                                                                     |
| `amount` | `number` | Yes      | Magnitude. For `earn` and `redeem` the absolute value is used; for `adjust` the sign is respected. |
| `source` | `string` | No       | Where the movement came from. Defaults to `manual`. Up to 60 characters.                           |
| `ref`    | `string` | No       | External reference — receipt or order number. Up to 120 characters.                                |
| `note`   | `string` | No       | Free text. Up to 300 characters.                                                                   |

### How `kind` maps to the balance

| `kind`   | `amount` sent | Stored `amount` | Effect                      |
| -------- | ------------- | --------------- | --------------------------- |
| `earn`   | `120`         | `+120`          | Adds.                       |
| `earn`   | `-120`        | `+120`          | Adds — the sign is ignored. |
| `redeem` | `50`          | `-50`           | Subtracts.                  |
| `adjust` | `-30`         | `-30`           | Subtracts, for corrections. |
| `adjust` | `30`          | `+30`           | Adds, for corrections.      |

## Example request

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.keebai.com/v1/loyalty/memberships/6650aaaa1111bbbb2222cccc/ledger \
    -H "Authorization: Bearer kbai_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "kind": "earn",
      "amount": 120,
      "source": "pos",
      "ref": "receipt-99120",
      "note": "Purchase of 12,000"
    }'
  ```

  ```js JavaScript theme={null}
  const response = await fetch(
    "https://api.keebai.com/v1/loyalty/memberships/6650aaaa1111bbbb2222cccc/ledger",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.KEEBAI_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        kind: "earn",
        amount: 120,
        source: "pos",
        ref: "receipt-99120",
      }),
    },
  );
  const { entry } = await response.json();
  console.log(`New balance: ${entry.balance_after}`);
  ```

  ```python Python theme={null}
  import os, requests

  resp = requests.post(
      "https://api.keebai.com/v1/loyalty/memberships/6650aaaa1111bbbb2222cccc/ledger",
      headers={"Authorization": f"Bearer {os.environ['KEEBAI_API_TOKEN']}"},
      json={"kind": "earn", "amount": 120, "source": "pos", "ref": "receipt-99120"},
      timeout=10,
  )
  resp.raise_for_status()
  entry = resp.json()["entry"]
  ```
</CodeGroup>

## Response

### 201 Created

```json theme={null}
{
  "entry": {
    "_id": "6650dddd3333eeee4444fffe",
    "membership_id": "6650aaaa1111bbbb2222cccc",
    "program_id": "6650a1b2c3d4e5f6a7b8c9d0",
    "kind": "earn",
    "metric": "points",
    "amount": 120,
    "balance_after": 120,
    "source": "pos",
    "ref": "receipt-99120",
    "note": "Purchase of 12,000",
    "created_at": "2026-07-26T21:38:44.008Z"
  }
}
```

### 400 Bad Request

`kind` outside the allowed set, a non-numeric `amount`, a field over its length limit, or a property the endpoint does not accept.

### 401 Unauthorized

Missing, invalid, revoked, or expired token.

### 403 Forbidden

The token does not have the `loyalty:write` scope.

### 404 Not Found

No membership with that id in your company.

### 502 Bad Gateway

The loyalty service is unreachable.

## Operational notes

<Warning>
  **Do not fire concurrent movements against the same membership.** The balance is read, modified, and written back rather than incremented atomically, so two simultaneous accruals can lose one of them. Serialise per membership — one call per transaction, not one per line item — or put them through a queue keyed by `membership_id`.
</Warning>

* **This call is not idempotent.** There is no deduplication on `ref`. A retry after a network timeout may double-count, so record the returned entry `_id` and check the ledger before retrying.
* **The balance never goes below zero.** Redeeming more than the customer has clamps to `0` and succeeds — it does not fail. If you need to reject an unaffordable redemption, check the balance first and decide on your side.
* **Nothing validates a redemption against a reward.** Loyalty has no reward catalogue: `redeem` just subtracts. What the customer receives is your business logic.
* **The wallet card updates on its own.** After the movement, the pass in Apple or Google Wallet is refreshed with the new balance. You do not need to call the notify endpoint for that — [notify](/dev/endpoints/loyalty-pass-notify) is for sending a message, not for syncing a number.
* **On a `stamps` program, `amount` is a stamp count.** Sending `amount: 1` adds one stamp. Sending `120` adds 120 stamps, which is almost certainly not what you meant — the program type is what you should branch on.
* **Tiers are recomputed on every movement**, against the point balance and the program's thresholds.


## OpenAPI

````yaml POST /v1/loyalty/memberships/{id}/ledger
openapi: 3.0.0
info:
  title: Keebai Public API
  description: Superficie pública REST autenticada con Personal Access Tokens (PAT).
  version: 1.0.0
  contact: {}
servers:
  - url: https://api.keebai.com
    description: Production
security: []
tags: []
paths:
  /v1/loyalty/memberships/{id}/ledger:
    post:
      tags:
        - loyalty
      summary: Registrar un movimiento de puntos o sellos
      description: >-
        Acumula, canjea o ajusta el saldo, y actualiza el pase en la wallet del
        cliente. La métrica la decide el tipo del programa, no el request. El
        saldo nunca baja de cero.
      operationId: PublicLoyaltyController_recordLedgerEntry
      parameters:
        - name: id
          required: true
          in: path
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicLedgerRecordDto'
      responses:
        '201':
          description: ''
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicLedgerEntryEnvelopeDto'
      security:
        - PAT: []
components:
  schemas:
    PublicLedgerRecordDto:
      type: object
      properties:
        kind:
          type: string
          enum:
            - earn
            - redeem
            - adjust
          description: >-
            earn suma, redeem resta, adjust respeta el signo de amount para
            correcciones
        amount:
          type: number
          description: >-
            Magnitud del movimiento. En earn y redeem se toma el valor absoluto;
            en adjust se respeta el signo
          example: 120
        source:
          type: string
          description: De dónde vino el movimiento. Por defecto manual
          example: pos-sucursal-centro
        ref:
          type: string
          description: Referencia externa, por ejemplo el número de boleta
          example: boleta-99120
        note:
          type: string
          example: Compra de 12.000 en tienda
      required:
        - kind
        - amount
    PublicLedgerEntryEnvelopeDto:
      type: object
      properties:
        entry:
          $ref: '#/components/schemas/PublicLedgerEntryDto'
      required:
        - entry
    PublicLedgerEntryDto:
      type: object
      properties:
        _id:
          type: string
        membership_id:
          type: string
        program_id:
          type: string
        kind:
          type: string
          enum:
            - earn
            - redeem
            - adjust
        metric:
          type: string
          enum:
            - points
            - stamps
          description: Lo decide el tipo del programa, no el request
        amount:
          type: number
          description: 'Movimiento con signo: negativo en un canje'
          example: -50
        balance_after:
          type: number
          description: Saldo resultante después del movimiento
          example: 1190
        source:
          type: string
          example: pos-sucursal-centro
        ref:
          type: string
          example: boleta-99120
        note:
          type: string
        created_at:
          type: string
      required:
        - _id
        - membership_id
        - program_id
        - kind
        - metric
        - amount
        - balance_after
        - source
        - ref
        - note
        - created_at
  securitySchemes:
    PAT:
      scheme: bearer
      bearerFormat: kbai_pk_<hex>
      type: http
      description: >-
        Personal Access Token con prefijo `kbai_pk_`. Generar desde el portal
        con permiso `developer.manage_tokens`.

````