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

# Reconcile experience action purchases

> After a fan buys a priced action: read the purchase back, grant or revoke from its status, handle events in any order and catch the webhooks you missed.

Use this page after a charge. A **priced action** is something a fan buys inside an experience your app runs, such as a photo critique. The fan pays the creator, and your app hands over what they bought. By the end of this page you can read any purchase back by its reference and decide from its status whether to grant or revoke. You can also handle the events in whatever order they arrive, and find the purchases whose webhook never reached you.

You need:

* an experience your app owns with at least one priced action. If you haven't built one yet, start with [Fan experiences](https://api.fanvue.com/docs/app-store/experiences/overview.md) and then [Payments inside experiences](https://api.fanvue.com/docs/app-store/experiences/payments.md), which covers declaring, pricing and charging actions
* an access token with `read:experience` for the purchase reads
* a webhook endpoint subscribed to the `app.experience.action.*` topics, which need `read:self`. [Subscribe to webhooks](https://api.fanvue.com/docs/webhooks/subscribing.md) covers that

The Builder SDK has no helper for the purchase endpoints, so call them over HTTP.

## How a charge moves

A purchase goes through five steps whether the fan paid in Fanvue's dialog or your server charged their wallet. Your app handles the last two, grant and reconcile.

| Step | Who | Where |
| - | - | - |
| Declare the action | Your app, in a publish request or a direct publish | [Publish experiences](https://api.fanvue.com/docs/app-store/experiences/publish.md) |
| Price it | The creator, in the confirmation dialog or through a `price-request`, or your app in a direct publish | [Publish experiences](https://api.fanvue.com/docs/app-store/experiences/publish.md) |
| Charge the fan | Fanvue's payment dialog from your fan surface, or a wallet charge from your server | [Payments inside experiences](https://api.fanvue.com/docs/app-store/experiences/payments.md) |
| Grant | Your app, on `app.experience.action.payment.succeeded` | [When to grant and when to revoke](#when-to-grant-and-when-to-revoke) |
| Reconcile | Your app, from the purchase list on a schedule | [Reconcile on a schedule](#reconcile-on-a-schedule) |

The creator is paid for each action at the standard Fanvue fee and your app receives no share, so a purchase carries no `net` or `fees`. [Who gets paid](https://api.fanvue.com/docs/app-store/experiences/payments.md#who-gets-paid) has the price limits.

## When to grant and when to revoke

Grant an action once per `purchaseReference`, and only when the purchase is `succeeded`, whether that comes from the `app.experience.action.payment.succeeded` event or from a read. Revoke the grant when the purchase becomes `refunded`, `disputed` or `cancelled`. The `purchase-result` message your fan surface receives only tells your UI what to show. [Fulfilment rule](https://api.fanvue.com/docs/webhooks/app/experience-actions.md#fulfilment-rule) is the canonical statement for handlers.

`status` on a purchase says where the money for that attempt is now.

| `status` | Meaning | What you do |
| - | - | - |
| `pending` | The attempt is open and the charge has not settled. It may never settle | Wait |
| `succeeded` | The charge settled and the money is the creator's | Grant |
| `failed` | The charge was declined | Nothing |
| `refunded` | The payment was refunded, or voided before it was captured | Revoke |
| `disputed` | A chargeback was lost and the payment reversed | Revoke |
| `cancelled` | The charge was cancelled before it was captured | Revoke |

Handle the three reversal values the same way. A purchase stays `succeeded` while a dispute is open, and after a refund attempt that did not complete, because in both cases the money stayed with the creator. Only the `app.experience.action.dispute.*` events tell you a purchase is contested.

## Handle events in any order

Fanvue delivers webhooks at least once and in no guaranteed order, as [Delivery, retries and idempotency](https://api.fanvue.com/docs/webhooks/delivery-and-idempotency.md) describes. For experience actions that means:

* A duplicate delivery carries the same envelope `id`. A grant keyed on `purchase_reference` makes it harmless.
* A `pending` can arrive after the `succeeded` for the same reference. Don't let it undo the grant. If events for one reference disagree, read the purchase back and act on its `status`.
* `app.experience.action.refund.created` can arrive before you have processed the `succeeded`. Record the reversal against the `purchase_reference` even when there is nothing to revoke yet, and check for it before you grant. A refund and a later chargeback on one purchase are two events with two ids, so make revocation idempotent too.

Only `app.experience.action.payment.succeeded` is replayed when it cannot be handed over. The other five events are best effort, so the purchase list is how you find the ones you missed.

## What to store

Key your purchases table on `purchaseReference`, because every event and every read carries it. Store `status` next to whether you granted the action, since reconciliation compares the two. Keep `invoiceNumber` to quote to Fanvue support and for accounting. Keep `clientReferenceId` to join the purchase to your own record, such as the round the fan was playing. `buyerUuid` is the same value as `fanUuid` from the [token exchange](https://api.fanvue.com/docs/app-store/experiences/build-the-fan-surface.md), so it joins purchases to the sessions your fan surface served. `walletBalance` on a charge response is a display snapshot and not worth persisting.

## Read one purchase

`GET /experiences/{experienceUuid}/action-purchases/{purchaseReference}` (`read:experience`) returns one purchase by the reference an event carried. Use it when a handler needs to confirm a purchase before acting.

```bash theme={null}
curl https://api.fanvue.com/experiences/e5a1c3d7-9b2f-4e6a-8c4d-1f7b3a9e2d58/action-purchases/expact_5c9a1f0e-9f3f-4c2a-8f31-1d0f6a2b7c44 \
  -H "Authorization: Bearer <token>" \
  -H "X-Fanvue-API-Version: 2025-06-26"
```

```json theme={null}
{
  "purchaseReference": "expact_5c9a1f0e-9f3f-4c2a-8f31-1d0f6a2b7c44",
  "experienceUuid": "e5a1c3d7-9b2f-4e6a-8c4d-1f7b3a9e2d58",
  "externalActionId": "critique",
  "clientReferenceId": "round-42",
  "buyerUuid": "4d8f2b6e-1c3a-4b9d-a7e5-6f0c2d8b1a93",
  "amount": 700,
  "currency": "USD",
  "status": "succeeded",
  "invoiceNumber": "INV-2026-004410",
  "createdAt": "2026-10-18T18:04:09.000Z",
  "paidAt": "2026-10-18T18:04:11.110Z"
}
```

A `404` on a reference that arrived in one of your own events means the request is wrong, not that the purchase is gone. Check that the path uses the experience from `data.experience.uuid`, and that the token was issued to the app that owns that experience.

## List purchases

`GET /experiences/{experienceUuid}/action-purchases` (`read:experience`) lists every attempt on one of your experiences, newest first, including ones that never settled. `limit` defaults to 50 with a maximum of 100. Paging is keyset: pass `nextCursor` from a response back as `cursor`. A purchase created while you page does not shift a row onto or off a later page.

```bash theme={null}
curl "https://api.fanvue.com/experiences/e5a1c3d7-9b2f-4e6a-8c4d-1f7b3a9e2d58/action-purchases?limit=2" \
  -H "Authorization: Bearer <token>" \
  -H "X-Fanvue-API-Version: 2025-06-26"
```

```json theme={null}
{
  "data": [
    {
      "purchaseReference": "expact_5c9a1f0e-9f3f-4c2a-8f31-1d0f6a2b7c44",
      "experienceUuid": "e5a1c3d7-9b2f-4e6a-8c4d-1f7b3a9e2d58",
      "externalActionId": "critique",
      "clientReferenceId": "round-42",
      "buyerUuid": "4d8f2b6e-1c3a-4b9d-a7e5-6f0c2d8b1a93",
      "amount": 700,
      "currency": "USD",
      "status": "succeeded",
      "invoiceNumber": "INV-2026-004410",
      "createdAt": "2026-10-18T18:04:09.000Z",
      "paidAt": "2026-10-18T18:04:11.110Z"
    },
    {
      "purchaseReference": "expact_2b7d9e41-6c0a-4f8e-9d2b-7a5c3e1f0b86",
      "experienceUuid": "e5a1c3d7-9b2f-4e6a-8c4d-1f7b3a9e2d58",
      "externalActionId": "critique",
      "clientReferenceId": null,
      "buyerUuid": "9c3e1f7a-2d4b-4a6c-8e0f-1b3d5f7a9c2e",
      "amount": 700,
      "currency": "USD",
      "status": "failed",
      "invoiceNumber": "INV-2026-004402",
      "createdAt": "2026-10-18T17:51:40.000Z",
      "paidAt": null
    }
  ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEwLTE4VDE3OjUxOjQwLjAwMFoifQ"
}
```

Only the app that owns the experience can call either read. Both answer `404` for anything outside your app: an experience owned by another app, or a reference that belongs to another app, another experience or nothing at all.

| Status | Body | Cause |
| - | - | - |
| `400` | `{ message }` | `cursor` could not be decoded, or `limit` is outside 1 to 100 |
| `404` | `{ message }` | Experience not found or not owned by your app, or no such purchase reference |
| `502` | `{ message }` | The purchases could not be read. Retry with capped backoff |
| `503` | `{ message }` | The purchase reads are temporarily unavailable. Retry later |

## Reconcile on a schedule

Run this pass for each experience your app owns. It finds the `succeeded` purchases whose event you never received and the reversals that arrived as best-effort events.

1. Call the list with no `cursor`, which starts at the newest purchase, and `limit=100`. Pass each `nextCursor` back until it is `null`. Walk to the end each time. A reversal changes `status` without moving the row, so stopping at purchases older than your last pass misses revocations. If a page answers `502`, retry it with the same `cursor`.
2. Grant each `succeeded` purchase that has no grant recorded against its `purchaseReference`. Its event was missed.
3. Revoke each grant whose purchase now reads `refunded`, `disputed` or `cancelled`.
4. Skip `pending` and `failed`. Nothing is owed on either.

## Test without a real charge

There is no sandbox, and [Test your app](https://api.fanvue.com/docs/get-started/test-your-app.md) explains what that means for a build. Each `app.experience.action.*` topic has a test payload, so you can exercise a handler without a charge. Send one with the **Test** button in the Events tab or with `POST /webhooks/test`, as [Test a destination](https://api.fanvue.com/docs/webhooks/subscribing.md#test-a-destination) describes. The Events tab refuses the test until the app owner has authorised the app with `read:self`. Test payloads carry `TEST-` prefixed ids inside `data` and describe no real purchase, so skip the read-back for a `TEST-` reference.

## Action purchase resource

Both reads return this resource. `POST /experiences/{experienceUuid}/action-purchases`, the wallet charge, returns it with one extra field, `walletBalance`.

| Field | Type | Nullable | Meaning |
| - | - | - | - |
| `purchaseReference` | string | no | The `expact_` reference minted with the attempt. Grant and revoke against it |
| `experienceUuid` | string | no | The experience the action belongs to |
| `externalActionId` | string | no | Your own stable key for the action, as you declared it |
| `clientReferenceId` | string | yes | The reference you passed with the purchase, or `null` when you passed none |
| `buyerUuid` | string | no | UUID of the fan. The resource carries no handle, display name or email |
| `amount` | integer | no | Price in USD cents, fixed when the fan attempted the purchase. Re-pricing the action later does not change it |
| `currency` | string | no | ISO 4217 code of `amount`. Always `USD` |
| `status` | string | no | Where the money is now. One of the six values in [When to grant and when to revoke](#when-to-grant-and-when-to-revoke) |
| `invoiceNumber` | string | no | Fanvue invoice number. For support and accounting, not for granting |
| `createdAt` | string | no | ISO 8601 time the attempt was opened |
| `paidAt` | string | yes | ISO 8601 time the charge settled, or `null` until it does |
| `walletBalance` | integer | no | On the `POST` response only. The fan's wallet balance in USD cents after the charge |

## Webhook field mapping

The events use snake\_case and the reads use camelCase. `purchase_reference`, `external_action_id`, `client_reference_id` and `paid_at` map to the camelCase field of the same name. The fields that differ:

| Event field (`data`) | Purchase field | Notes |
| - | - | - |
| `id` on a payment event | `invoiceNumber` | On a refund event, `payment_id` carries the reversed payment's invoice number instead |
| `gross` on a payment event | `amount` | A refund event carries `amount` instead, the gross that was reversed |
| `buyer.uuid` | `buyerUuid` | Nested object on the event |
| `experience.uuid` | `experienceUuid` | Nested object on the event |
| `status` | `status` | A payment event carries only `pending`, `succeeded` or `failed`. Reversals arrive as their own events, and the read shows them as `refunded`, `disputed` or `cancelled` |
| `metadata` | none | Always `{}` on this rail. Experience action purchases accept no metadata |

[App events: experience actions](https://api.fanvue.com/docs/webhooks/app/experience-actions.md) has every payload.

## Refunds and disputes

Refunds have no API on this rail. `app.experience.action.refund.created` fires when a settled purchase is reversed, with the same `purchase_reference` and a `reason` of `refund`, `chargeback` or `cancel`. The read also records reversals of attempts that never settled, so a change of `status` on the list is the signal to act on whether or not an event arrived. Revoke only what your app granted against that reference. A refunded paid-access unlock loses access on the fan's next launch without any action from you.

`app.experience.action.dispute.flagged` and `app.experience.action.dispute.created` are advisory. The money is still the creator's while a dispute is open, and the purchase still reads `succeeded`. [Dispute resource](https://api.fanvue.com/docs/webhooks/app/experience-actions.md#dispute-resource) has the payloads.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.