# Create loyalty points export

Request a CSV or JSON file of recorded reward-coin history for a date range.

- Endpoint: `POST https://api.returning.ai/v1/loyalty-exports`
- Section: Gamification / Loyalty points
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `customerSuccess` (Shown in the dashboard as Customer Success.)
- Retries: Retry uncertain requests with the same API key, Idempotency-Key and body.
- Guide: hand-written
- Verified: live, 2026-10-12
- Last updated: 12 Oct 2026
- Web page: https://docs.returning.ai/api-reference/gamification-loyalty-points/create-loyalty-points-export

## When to use this

- Load daily reward-coin history into your reporting system.
- Extract a historical period across all customers or selected customer identifiers.

**Instead:** Use [Get loyalty overview](https://docs.returning.ai/api-reference/community-analytics/get-loyalty-overview.md) for balance summaries, or [List redemption orders by community](https://docs.returning.ai/api-reference/rewards-and-redemptions/list-redemption-orders-by-community.md) for store orders.

## Authentication

- Header: `Authorization: Bearer <API_KEY>`
- Permission: `customerSuccess`

Use a Community API key with `customerSuccess`, shown as Customer Success in the dashboard. Keep the key on your backend and send `Authorization: Bearer <API_KEY>`. The key selects the community; do not send a community ID. Personal API keys are not supported.

All three calls use `https://api.returning.ai` and return `Cache-Control: no-store`. They accept no query parameters. A valid community key with the required permission can read that community's export jobs, including jobs created by another key in the same community.

Successful responses contain `meta`, `message` and `data`. Read job state from `data.status`. An HTTP `200` means the status request succeeded, even when the job is `failed`. Errors put their machine-readable code in `meta.code`; do not branch on `message`.

## Behaviour

Request export -> save `export_id` -> poll status -> get download link -> download the file.

This creates a report job and does not change balances. The dataset excludes XP and premium currency. It is not a store-orders feed, current-balance snapshot or pre-calculated BI totals.

### Start here: a daily export

You need a backend process, a Community API key with Customer Success permission, and somewhere private to store the downloaded file. The examples on this page use synthetic dates and identifiers; choose a completed period that contains your own activity.

| Step | Call | Save or check |
| --- | --- | --- |
| 1. Request the file | `POST /v1/loyalty-exports` | Save your request body and idempotency key before sending. Save `data.export_id` from the response. |
| 2. Wait for completion | `GET /v1/loyalty-exports/{exportId}` | Continue only for `queued` or `processing`. Download only after `completed`. |
| 3. Get a temporary link | `GET /v1/loyalty-exports/{exportId}/download` | Use `data.url` promptly. Do not log it. |
| 4. Download and import | HTTPS GET to `data.url` | Send no API key. Check row count and byte size against status, then import. |

The three API calls need your key. The file download uses the temporary URL instead. There is no webhook, list-jobs or public cancel call in this three-endpoint workflow, so retain the export ID yourself and poll status.

### Choose the date window

The filter uses **when each coin-history entry was recorded**, not when its original trade or activity happened. `start_at` is included and `end_at` is excluded. Both must be UTC timestamps ending in `Z`, with at most three fractional-second digits.

| Reporting day | `start_at` | `end_at` |
| --- | --- | --- |
| 1 September in UTC | `2026-09-01T00:00:00.000Z` | `2026-09-02T00:00:00.000Z` |
| 1 September in a UTC+08:00 business timezone | `2026-08-31T16:00:00.000Z` | `2026-09-01T16:00:00.000Z` |

For a timezone that observes daylight saving, convert each local midnight separately; a local day can be 23 or 25 hours. The API does not accept a timezone parameter. Run your daily process after the interval closes: an end time in the future is rejected.

Adjacent days share one boundary. A record exactly at `2026-09-02T00:00:00.000Z` belongs to the next UTC day, not both. Historical backfills can use wider intervals, but use sequential windows for easier recovery. There is no maximum range duration in the request validator and no promised completion time or unlimited file size.

### Copy-and-run CSV example

[Download the complete Python example](https://docs.returning.ai/examples/export-loyalty-points.py). It requires Python 3.10 or newer and no third-party packages. Supply your API key through your backend's environment or secret manager as `RETURNING_API_KEY`.

```bash
python3 export-loyalty-points.py \
  --start '2026-09-01T00:00:00.000Z' \
  --end '2026-09-02T00:00:00.000Z' \
  --state export-2026-09-01.state.json \
  --out points-2026-09-01.csv
```

Omit `--client-ref` to include all customers. To select customers, add `--client-ref '000101' --client-ref '000102'`. For a single date range, use the same selection on every resume.

The example persists an idempotency key before creation, saves the export ID, polls with backoff, downloads without forwarding the API key, and checks the CSV header, row count and byte size. It streams CSV to a temporary file and replaces the output only after those checks pass. An empty report still produces a valid header-only CSV.

If it times out, rerun the **same command with the same state file and API key**. The default ten-minute polling budget is a client choice, not an API guarantee. `--max-wait 1200` changes that budget. If a request is accepted but the response is lost, the saved key lets the next run recover the original job.

Use one process per state file and serialize exports for each community. Keep state and CSV files private. Use a distinct state file for each deliberately new report; deleting state or changing API keys after an uncertain request can create a different job. The example is a starting point for a backend process, not a scheduler or warehouse importer. It replaces the chosen output file after verification, so choose an export-specific output path.

### Manual status and download calls

After the create request shown below returns an export ID, use it in both follow-up URLs:

```bash
curl --fail-with-body \
  'https://api.returning.ai/v1/loyalty-exports/66f000000000000000000701' \
  --header 'Authorization: Bearer <API_KEY>'
```

Wait until `data.status` is `completed`, then request the link:

```bash
curl --fail-with-body \
  'https://api.returning.ai/v1/loyalty-exports/66f000000000000000000701/download' \
  --header 'Authorization: Bearer <API_KEY>'

curl --fail --output points.csv '<TEMPORARY_DOWNLOAD_URL>'
```

Replace the synthetic export ID with your returned ID and the final URL with `data.url`. Copy the full URL, including its query string, and keep it quoted. The last command has no Authorization header.

## Request

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | Yes | Community API key with Customer Success permission. The key selects the community. (Bearer <API_KEY>) |
| `Idempotency-Key` | `string` | Yes | Unique key for one intended export; reuse with the same body after an uncertain response. (1-128 characters; first ASCII letter, digit, _ or -; remaining characters may also include . and :) |
| `Content-Type` | `string` | Yes | Request body format. (`application/json`) |

### Body

The body must be JSON and at most 1 MiB. Unknown body fields are rejected. An empty `client_refs` array is invalid; it does not mean all customers.

To select customers and request JSON, use:

```json
{
  "dataset": "points_transactions",
  "format": "json",
  "start_at": "2026-09-01T00:00:00.000Z",
  "end_at": "2026-09-02T00:00:00.000Z",
  "client_refs": ["000101", "000102"]
}
```

References use your community's active customer identifier. Do not assume this is the Returning.AI user ID. Text identifiers preserve case, spaces and leading zeroes. Numeric identifiers use canonical numeric text: `000101` does not match a numeric identifier stored as `101`.

Selection is resolved during processing. An unknown or ambiguous selected identifier fails the job rather than returning a partial selection. Without a selection, unresolved identifiers can appear as null in JSON or blank in CSV. Identifier values are resolved when the report runs, not as they were at the historical event time.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `dataset` | `string` | Yes | Recorded ordinary reward-coin history. XP and premium currency are excluded. (points_transactions) |
| `format` | `string` | Yes | Output file format. JSON has additional detail fields. (csv or json) |
| `start_at` | `string` | Yes | Inclusive UTC recording time, ending in Z. (At most three fractional-second digits) |
| `end_at` | `string` | Yes | Exclusive UTC recording time after start_at and no later than now. (No maximum range duration) |
| `client_refs` | `string[]` | No | Optional exact customer identifiers from the community's active identifier field. Omit for all customers. (1-1,000 unique nonblank strings, each at most 1,024 characters; no control characters) |

### Example request

```bash
curl --request POST \
  --url https://api.returning.ai/v1/loyalty-exports \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Idempotency-Key: 9b629318-5c67-4799-8135-27a64f1fc529' \
  --header 'Content-Type: application/json' \
  --data '{
    "dataset": "points_transactions",
    "format": "csv",
    "start_at": "2026-09-01T00:00:00.000Z",
    "end_at": "2026-09-02T00:00:00.000Z"
  }'
```

## Response

New jobs return `202`. An exact replay returns `200` with the original job's current state. Save `data.export_id`; acceptance does not mean the file is ready. There is no success `meta.code` or returned polling interval.

### File contents and daily loads

CSV is UTF-8 with these five columns, in this order:

| Column | Meaning |
| --- | --- |
| `client_ref` | Configured customer identifier; blank if unresolved. Import it as text. |
| `event_id` | Stable recorded-history identifier for recognizing the same event across overlapping exports. Treat it as opaque. |
| `recorded_at_utc` | UTC recording time used by the date filters. It is not necessarily the original trade or activity time. |
| `operation` | `increase`, `decrease`, `overwrite`, `expiry` or `unknown`. |
| `points_delta` | Stored numeric amount as text, or blank when unknown. For `overwrite`, this is the assigned balance, not a movement. |

Ordinary zero-movement rows are excluded. An overwrite that sets the balance to zero is retained. Do not sum every `points_delta` as points earned: handle overwrites separately and treat unknown values as unknown, not zero.

Rows are newest first by recording time, with a stable identifier tie-breaker. The date interval is inclusive at `start_at` and exclusive at `end_at`.

JSON is an array, not JSONL. It includes the five CSV fields plus `schema_version`, `community_id`, `returning_user_id`, `occurred_at_utc`, `currency_type`, `absolute_balance`, `raw_action`, `source_reference` and `quality_notes`. `schema_version` is `1`, `currency_type` is `reward`, and `occurred_at_utc` is null. `absolute_balance` is populated for overwrites. `raw_action` is JSON-encoded text, while `quality_notes` is an array of strings. Extra JSON fields may contain personal data; protect the whole file.

`source_reference` is not necessarily a trade or order ID. `operation` does not reliably classify trade, mission or redemption reasons. Quality notes identify uncertainties such as missing identifiers or original activity times.

CSV text that could be interpreted as a spreadsheet formula receives a leading apostrophe; signed amounts do not. Use JSON when your automated import needs original text without spreadsheet escaping.

### Example CSV records

These illustrative records show the five-column layout. Real `event_id` values are opaque strings; keep them exactly as received. The rows are ordered newest first.

```csv
client_ref,event_id,recorded_at_utc,operation,points_delta
000101,<eventId-3>,2026-09-01T14:00:00.000Z,overwrite,40
000101,<eventId-2>,2026-09-01T13:00:00.000Z,decrease,-10
000101,<eventId-1>,2026-09-01T12:00:00.000Z,increase,25
```

For an empty CSV, only the header line is present. For empty JSON, the file contains `[]`.

### JSON field reference

JSON is useful when you need extra context or original identifier text without CSV spreadsheet escaping. It is a single array of objects. Read numeric amounts as numeric text and preserve identifier strings; avoid converting `client_ref` to a number.

| JSON field | Type | Meaning |
| --- | --- | --- |
| `schema_version` | integer | Currently `1`. |
| `community_id` | string | Community owning the recorded history. |
| `client_ref` | string or null | Configured customer identifier resolved when the report runs. |
| `returning_user_id` | string | Returning.AI user identifier represented as text. |
| `event_id` | string | Opaque history identifier. Use it to recognize the same event in overlapping files. |
| `recorded_at_utc` | string | UTC timestamp used by the date filter. |
| `occurred_at_utc` | null | Original activity time is not available in this dataset. |
| `currency_type` | string | `reward`. |
| `operation` | string | `increase`, `decrease`, `overwrite`, `expiry` or `unknown`. |
| `points_delta` | string or null | Recorded amount. For overwrite, the assigned balance rather than a movement. |
| `absolute_balance` | string or null | Assigned balance for an overwrite; otherwise null. |
| `raw_action` | string | JSON-encoded original action data; parse only if your use case needs it. |
| `source_reference` | string or null | Optional correlation reference, not a guaranteed trade/order identifier. |
| `quality_notes` | array of strings | Qualifications on the record's interpretation. |

Amounts use the source's native numeric precision and may appear in scientific notation. A string does not imply arbitrary-precision accounting. A missing or invalid amount becomes null in JSON or blank in CSV; do not silently turn it into zero.

### Interpreting points correctly

For `increase` and `decrease`, keep the amount's sign. Do not make every amount positive. For `overwrite`, the amount assigns a balance and cannot be added to earned points.

For example, if a balance was 100 and an overwrite assigns 40, a row with `operation: "overwrite"` and `points_delta: "40"` does not mean the trader earned 40. You cannot calculate its -60 effect from that row alone unless you have a trustworthy prior balance. In JSON, `absolute_balance` also contains `"40"`.

`expiry` is distinguished from ordinary decreases. `unknown` means the amount or classification is not known well enough; retain it for review. No operation value reliably tells you whether the business reason was trading, a mission or a store purchase.

| Quality note | Interpretation |
| --- | --- |
| `original_event_time_not_recorded` | Use recording time; do not infer the original trade/activity time. |
| `business_reason_not_verified` | Do not infer a business category from the row alone. |
| `client_ref_unresolved` | This row could not be mapped to a configured customer identifier. |
| `legacy_number_precision_unverified` | Numeric precision of the original value is not certified. |
| `points_amount_invalid` | Treat the amount as unknown, not zero. |
| `absolute_overwrite_not_an_earn` | This row assigns a balance; exclude it from a simple earned-points sum. |
| `expiry_sign_unexpected` | Review the expiry amount's sign before aggregating. |

Treat quality notes as an extensible list: retain unrecognized notes instead of rejecting the whole file.

### Daily loads

Request consecutive UTC recording-time windows. Use `event_id` to avoid duplicate rows when intentionally repeating or overlapping windows. A new request rereads history; an idempotent replay returns the same job.

The export is not a transactional snapshot or a guaranteed change feed. Late records and historical edits can make a fresh export differ. Deleted historical events are not supplied as deletion records. `coverage: observed_history_only` means recorded history, not certified financial completeness. The export does not reconstruct every historical balance.

For current balance summaries, use [Get loyalty overview](https://docs.returning.ai/api-reference/community-analytics/get-loyalty-overview). For order details and fulfilment state, use [List redemption orders by community](https://docs.returning.ai/api-reference/rewards-and-redemptions/list-redemption-orders-by-community).

### Make a daily import reliable

1. Choose the closed recording-time window and persist the request body, API key identity, idempotency key and eventual export ID in your own job record.
2. Run only one active export for the community. On an uncertain create response, retry exactly; do not advance to the next window.
3. After completion, download the file promptly, check its row count and byte size, and store it privately. A byte-size check detects an incomplete transfer; the API does not supply a cryptographic checksum.
4. Upsert by `event_id` in your reporting store. Preserve `client_ref` as text, retain null/unknown values and quality notes, and handle overwrite rows separately.
5. Advance your own import checkpoint only after the entire file has been imported successfully. If an import partially fails, reuse the saved file or download it again while available, and repeat the upsert.
6. If your business needs reconciliation, deliberately re-export overlapping closed windows with new idempotency keys and deduplicate by `event_id`. Choose the overlap from your own source delays; the API does not promise a maximum lateness interval or a deletion feed.

This avoids checking each event against your database before requesting the API: the date filter selects the period and your import's upsert handles replayed rows. It does not add date filtering to the separate store-orders API.

### Common questions

| Question | Answer |
| --- | --- |
| Can I request yesterday only? | Yes. Send yesterday's UTC recording-time boundaries, or convert the boundaries of your business timezone to UTC. |
| Does it return one row per customer with their balance? | No. It returns recorded coin-history events. Use Get loyalty overview for current balance summaries. |
| Does it return store orders or ready-made BI totals? | No. Use the order endpoints for orders and aggregate the exported history in your reporting system with the interpretation rules above. |
| Can I export all customers without paging? | Yes. Omit `client_refs`; one job creates one file for the matching history. These endpoints have no page or cursor parameter. |
| Why is a successful file empty? | No included history matched the requested interval and selection. Current balances alone do not imply activity in that interval. |
| Does retrying refresh old data? | An idempotent replay returns the original job. Use a new key for a deliberately fresh extraction. |
| Can I recover a lost export ID? | Retry the original body with the original key and API key. This family has no list-jobs endpoint, so save the ID yourself. |
| Can I use the file after its link expires? | If the file is still available, get a new link. After the 24-hour file window expires, request a new export. |

### Response fields

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| `meta` | `object` | always | Request result and support reference. |
| `meta.status` | `string` | always | Result of the request. (success) |
| `meta.statusCode` | `integer` | always | HTTP status of the request. |
| `meta.requestId` | `string` | always | Support reference for this request. |
| `message` | `string` | always | Human-readable summary. Do not branch on it. |
| `data` | `object` | always | Export job details. |
| `data.export_id` | `string` | always | Save this ID for status and download requests. |
| `data.status` | `string` | always | Current job state. Acceptance does not mean the file is ready. (queued, processing, completed, failed, cancelled or expired) |
| `data.dataset` | `string` | always | The dataset requested. (points_transactions) |
| `data.format` | `string` | always | The requested file format. (csv or json) |
| `data.created_at` | `string` | always | Job creation timestamp in UTC. |
| `data.coverage` | `string` | always | Recorded history only, not certified financial completeness. (observed_history_only) |

### Example response (200)

```json
{
  "meta": {
    "status": "success",
    "statusCode": 200,
    "requestId": "example-request"
  },
  "message": "Loyalty export accepted success.",
  "data": {
    "export_id": "66f000000000000000000701",
    "status": "queued",
    "dataset": "points_transactions",
    "format": "csv",
    "created_at": "2026-09-03T01:00:00.000Z",
    "coverage": "observed_history_only"
  }
}
```

## Errors

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Remove unsupported query parameters and correct the request fields. |
| 401 | `AUTHENTICATION_REQUIRED` | Send a valid Community API key in Authorization: Bearer <API_KEY>. |
| 403 | `API_KEY_PERMISSION_DENIED` | Use a Community API key with Customer Success permission. |
| 403 | `COMMUNITY_ACCESS_DENIED` | Use a Community API key, not a personal API key. |
| 400 | `INVALID_JSON` | Send valid JSON. |
| 409 | `IDEMPOTENCY_CONFLICT` | Restore the original body for a retry. Use a new key only for a deliberately new report. |
| 413 | `REQUEST_TOO_LARGE` | Reduce the JSON body to at most 1 MiB. |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | Send UTF-8 application/json with a supported content encoding. |
| 400 | - | Invalid request or unsupported query parameters. |
| 401 | - | Missing or invalid API key. |
| 403 | - | A Community API key with the required permission is needed. |
| 413 | - | Request body exceeds 1 MiB. |
| 415 | - | Use UTF-8 application/json. |

### Fix the data

| Status | Code | What to do |
| --- | --- | --- |
| 404 | `COMMUNITY_NOT_FOUND` | Contact support about the community selected by your API key. |
| 409 | - | Same key was used with different input. |

### Retry with backoff

| Status | Code | What to do |
| --- | --- | --- |
| 503 | `EXPORTS_UNAVAILABLE` | Retry with backoff. For create, preserve the original body and idempotency key. |
| 500 | `AUTHENTICATION_FAILED` | Retry with backoff; contact support if authentication checks keep failing. |
| 429 | `EXPORT_IN_PROGRESS` | Wait at least Retry-After, currently 30 seconds. Check the active export status. |
| 429 | - | A points export is already active; Retry-After: 30. |
| 503 | - | Exports disabled or unavailable. Retry creation with the same idempotency key. |

**Retries:** Idempotency is scoped to the community, API key ID and `Idempotency-Key`. Use the same API key, header and body after a timeout or uncertain response. Switching API keys does not replay the original request.

Dates are normalized to UTC millisecond strings and selected identifiers are sorted before comparison. Reordering the same selection is a replay. Reusing a key with different input returns `409 IDEMPOTENCY_CONFLICT`. Omitted selection and an explicit selection are different requests.

A replay returns the original job; it does not restart it, refresh its file or extend retention. There is no documented minimum idempotency-retention guarantee. Use a new key only for an intentional new extraction.

Only one export may be active per community. `429 EXPORT_IN_PROGRESS` includes `Retry-After: 30`. Wait at least that long before retrying. If a known job remains queued for an unusually long time, contact support with its export ID and request ID. Changing the date range or creating new idempotency keys will not unblock it.

## Next step

- [Get loyalty points export status](https://docs.returning.ai/api-reference/gamification-loyalty-points/get-loyalty-points-export-status.md): `GET /v1/loyalty-exports/{exportId}`. Save export_id and poll until the job reaches a terminal state.
