---
title: "Send Custom Store Events"
method: POST
path: "/v2/custom-stores/{custom_store_id}/events"
tags: ["Custom Store Events"]
---

# Send Custom Store Events

`POST /v2/custom-stores/{custom_store_id}/events`

## Request

- Base URL: `https://api.superwall.com`
- URL: `https://api.superwall.com/v2/custom-stores/{custom_store_id}/events`
- Auth: one of:
  - HTTP bearer
  - API key in cookie `accounts_superwall_token`
  - HTTP bearer

Reports subscription lifecycle events (purchases, renewals, cancellations, refunds, ...) from a custom store. Accepted events update the user's entitlements, are delivered to your webhooks and count toward revenue charts (`sandbox` events only when charts are filtered to the Sandbox environment).

Send 1-100 events per request. A malformed request is rejected as a whole with 400: an unknown field anywhere in the body (a misspelled event or price field included), an unknown event type, a timestamp that is not a real ISO 8601 instant with a timezone in years 2000-9999, `app_user_id` with surrounding whitespace, or a price amount that is not a decimal string.

Otherwise the response is 200 with one result per event, in request order: `accepted`, or `rejected` with an error `code`, a `message` and the offending field as `param`. A rejection is always a problem with that event (an unknown product, a timestamp more than 5 minutes ahead of our clock, a missing price, a currency we have no exchange rate for); correct it before resending.

A failure on our side never rejects single events: the whole request fails, with 503 `custom_store_ingestion_failed` (`retryable: true`; resend the same batch unchanged) or, for an internal error, 500. The request is answered within 20 seconds; on a timeout, resend too. Environments that do not record custom store events answer 503 `custom_store_ingestion_unavailable` (`retryable: false`).

`event_id` is the idempotency key: re-sending an event never applies it twice, so retrying a batch is always safe. Never re-send an `event_id` with a different body: it is accepted, but analytics may keep either body and can count it twice. Correct a mistake with a new event. Events may arrive out of order; `occurred_at` decides which change is newest.

Prices convert to USD at our current exchange rate when the event is received, so the USD value of a resent event can differ slightly from the first.

The first accepted `production` event sets the store's `reporting_since` (see the custom stores API) to the time that request was received: from then on, revenue for this store's products is counted from these events rather than from SDK purchase events. It never moves on its own afterwards.

Rate limited per custom store: a 429 carries Retry-After.

Requires custom_store_events:write scope.

## Path parameters

- `custom_store_id` string, required

## Request body

- object
  - `events` CustomStoreEvent[], required
    - `event_id` string, required
    - `type` 'initial_purchase' | 'renewal' | 'product_change' | 'cancellation' | 'uncancellation' | 'billing_issue' | 'expiration' | 'refund' | 'non_renewing_purchase', required: The lifecycle change. - `initial_purchase`: the first period of a subscription started (including a free trial). - `renewal`: a new paid period started. - `product_change`: the subscription moved to another product; `product_id` is the new product. It carries no price: report any charge for the new product as a `renewal`. - `cancellation`: auto-renew was turned off. Access continues until `expires_at`. - `uncancellation`: auto-renew was turned back on before expiry. - `billing_issue`: a renewal payment failed and is being retried. Access continues until `expires_at`. - `expiration`: the subscription ended. Access stops. - `refund`: the current period was refunded in full. Access stops immediately and the subscription ends: cancel it in your billing system too, so the customer is not billed again. A refunded `subscription_id` is final: no later event restores its access, so a customer who subscribes again needs a new `subscription_id`. Partial refunds are not supported: do not report them. - `non_renewing_purchase`: a one-time purchase; send `expires_at: null` for lifetime access.
    - `app_user_id` string, required
    - `product_id` string, required: The identifier of a product of this custom store, exactly as created (bare, without the SDK's `custom|` prefix). A new purchase (`initial_purchase`, `non_renewing_purchase`) needs an active product; every other event also accepts an archived one, so existing subscribers keep renewing, cancelling and expiring after you archive their product.
    - `subscription_id` string, required: Your stable id for the subscription, constant across renewals (the original transaction). For a `non_renewing_purchase`, the purchase id.
    - `transaction_id` string, required: Your id for this period's transaction or invoice.
    - `custom_transaction_id` string
    - `environment` 'production' | 'sandbox', required: `sandbox` for test purchases. Sandbox events are stored and appear in revenue charts only when they are filtered to the Sandbox environment; they never set the store's `reporting_since`.
    - `occurred_at` string, required
    - `purchased_at` string, required
    - `expires_at` string, nullable, required: End of the current period. Must not be before `purchased_at`. `null` means the period never ends, so it is accepted only where it grants no open-ended access: on a lifetime `non_renewing_purchase`, and on `expiration` and `refund`, which end access regardless. Every other type rejects `null` with `expires_at_required`.
    - `period_type` 'normal' | 'trial' | 'intro', required: `trial` for a free trial, `intro` for an introductory price, otherwise `normal`.
    - `price` object: What the customer paid for this period. Required on revenue events (`initial_purchase`, `renewal`, `non_renewing_purchase`, `refund`) and rejected on the others. Send `0` for a free trial.
      - `amount` string, required
      - `currency` string, required
    - `cancel_reason` 'unsubscribe' | 'billing_error' | 'developer_initiated' | 'price_increase' | 'customer_support' | 'unknown': Why the subscription was cancelled or refunded. Only on `cancellation`, `expiration` and `refund`.
    - `is_trial_conversion` boolean: `true` on the `renewal` that converts a free trial into a paid period.

## Response `200`

Success

- object
  - `object` 'custom_store_event_batch', required
  - `custom_store_id` string, required
  - `accepted` number, required: Number of accepted events
  - `rejected` number, required: Number of rejected events
  - `results` object[], required: One result per event, in request order
    - `event_id` string, required
    - `status` 'accepted' | 'rejected', required
    - `error` object
      - `code` 'unknown_product' | 'product_archived' | 'occurred_at_in_future' | 'purchased_at_in_future' | 'expires_before_purchase' | 'expires_at_required' | 'price_required' | 'price_not_applicable' | 'cancel_reason_not_applicable' | 'unsupported_currency' | 'invalid_app_user_id' | 'duplicate_event_id', required
      - `message` string, required
      - `param` string, required: Path of the offending field in the request, e.g. `events[2].product_id`.

## Other responses

- `400`: The request did not match the expected schema | The request was invalid or malformed
- `401`: No API key was provided in the request | The API key does not have a valid format | The API key is invalid or has been revoked | An error occurred while validating the API key | The session cookie is invalid or expired
- `403`: The API key does not have permission to perform this action
- `404`: The requested resource was not found
- `429`: Too many requests have been made in a short period
- `500`: An unexpected error occurred on the server
- `503`: The batch was not recorded. With `retryable: true`, resend the same batch unchanged; `event_id` makes that safe. With `retryable: false`, custom store events are not available on this environment.

## Changes

- **2026-10-29** `778a11cd2c15`: 5 warning, 2 info
  - added the new `credentials_missing` enum value to the `anyOf[subschema #2]/code` response property for the response status `400`
  - added the new `credentials_missing` enum value to the `code/anyOf[subschema #1]/` response property for the response status `403`
  - added the new `credentials_missing` enum value to the `code/anyOf[subschema #1]/` response property for the response status `404`
  - added the new `credentials_missing` enum value to the `code/anyOf[subschema #1]/` response property for the response status `429`
  - …3 more
- **2026-10-28** `b3fbbd82cfe5`: 2 warning
  - added the new `expires_at_required` enum value to the `results/items/error/anyOf[subschema #1]/code` response property for the response status `200`
  - added the new `product_archived` enum value to the `results/items/error/anyOf[subschema #1]/code` response property for the response status `200`
- **2026-10-21** `b43ec4cfe172`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/superwall/apis/superwall-api-v2/changes/v2/custom-stores/:custom_store_id/events/post.md)

---

[API](https://skmtc.dev/superwall/apis/superwall-api-v2.md) - [All operations](https://skmtc.dev/superwall/apis/superwall-api-v2/llms.txt) - [OpenAPI document](https://skmtc.dev/superwall/apis/superwall-api-v2/revisions/523b4734dfab?raw)
