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

# Reval API: query your subscriptions from your own tools

> GraphQL API over subscriptions, orders, customers, plans and boxes: queries and actions (pause, cancel, skip, change products, address, portal link, checkout links). Per-store API keys, idempotency, pagination, filters, limits and examples.

The Reval API exposes your store's subscription data so you can pull it into your BI, your data warehouse or your own automations, and lets you **act** on them: pause, cancel, skip, change products and address. It is **GraphQL**, and every key sees and operates only the store that generated it.

> [!NOTE]
>
> Queries are in the sections below; actions are under [Actions](#actions). The full list of types and fields is in the [schema reference](https://docs.appreval.com/en/developers/api-reference.md), and there are [recipes](https://docs.appreval.com/en/developers/recipes.md) for the most common cases. To learn what happens without polling, see the [webhooks](https://docs.appreval.com/en/developers/webhooks.md).

***

## Authentication

Keys are generated in **Reval → Integrations** in your Shopify admin. Name each key after the tool that will use it ("Triple Whale", "Looker") and save it right away: **it is shown in full exactly once**. Afterwards the list only shows its prefix.

Send the key on every request as a Bearer token:

```http theme={null}
POST https://api.appreval.com/api/graphql
Authorization: Bearer reval_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
```

If your client cannot set `Authorization`, the `X-Reval-Api-Key` header is equivalent.

* A revoked key stops working immediately. Requests get `401` with code `UNAUTHENTICATED`.
* Reval stores only a hash of the key. If you lose it, revoke it and generate another.
* You can have up to **25 active keys** per store, each with a name of up to 60 characters. The list shows each key's last use, so you know which ones to revoke.
* When you generate a key you choose **read and write** or **read-only**. A read-only key queries everything but cannot run actions (see [Actions](#actions)). The type cannot be changed later: revoke and generate another.

> [!WARNING]
>
> Responses include your customers' personal data (email, address, phone, tax id). Treat keys like a password: never commit them to public code or share them over chat.

***

## First query

```bash theme={null}
curl https://api.appreval.com/api/graphql \
  -H "Authorization: Bearer reval_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ shop { myshopifyDomain } subscriptions(first: 5, filter: { status: [ACTIVE] }) { totalCount nodes { id customerEmail amount currency nextPaymentDate } } }"}'
```

```json theme={null}
{
  "data": {
    "shop": { "myshopifyDomain": "tu-tienda.myshopify.com" },
    "subscriptions": {
      "totalCount": 312,
      "nodes": [
        { "id": "cmgabc123…", "customerEmail": "ana@example.com", "amount": "18500.00", "currency": "ARS", "nextPaymentDate": "2026-11-02T13:00:00.000Z" }
      ]
    }
  }
}
```

The endpoint supports **introspection**, so any GraphQL client (GraphiQL, Insomnia, Postman, Altair) shows you the full schema with every field's description. This page is the overview; the schema is the reference.

* Only `POST` with a JSON body `{ "query", "variables", "operationName" }` is accepted: one operation per request, no batching and no `GET`.
* The endpoint answers CORS, so a browser tool can call it. Do not do that from your storefront: the key would be exposed to anyone who opens the page source.

***

## What you can query

| Query | Returns |
| - | - |
| `shop` | The store's myshopify domain and language |
| `subscription(id)` / `subscriptions` | Subscriptions with products, plan, frequency, status, next charge, price, shipping, tax, masked card and status/price history |
| `order(id)` / `orders` | Orders generated by subscriptions: one per charge, with cycle number, amount and the Shopify order id |
| `customer(email)` / `customers` | Customers (by email) with their subscriptions and orders |
| `planGroup(id)` / `planGroups` | Plans as you see them in **Plans**: cadence, price type, discounts, min and max cycles |
| `plan(id)` / `plans` | Each product (variant) attached to a plan |
| `box(id)` / `boxes` | Boxes with their plans, sizes, eligible products, add-ons and tiers |

Objects that exist in Shopify carry their **Shopify GID** so you can join them against the Admin API: `shopifyOrderId`, `shopifyCustomerId`, `shopifyVariantId`, `shopifyProductId`. Mercado Pago identifiers (`mercadoPagoPreapprovalId`, `mercadoPagoPaymentId`) let you reconcile against your MP account; credentials are never exposed.

### Conventions

* **Amounts** (`Money`) are strings with two decimals: `"18500.00"`. The currency is the `currency` field on the same object.
* **Dates** (`DateTime`) are ISO-8601 in UTC: `"2026-11-02T13:00:00.000Z"`.
* **Enums** are upper-case: `ACTIVE`, `PAUSED`, `CANCELLED`, `MONTHS`, `PERCENT`, `DYNAMIC_BOX`.
* Reval ids are opaque; always use the one the API returned.

***

## Pagination

Every list is a Shopify-style *connection*: ask for `first` items and continue with `after` using the previous page's `endCursor`.

```graphql theme={null}
query ($after: String) {
  orders(first: 100, after: $after, filter: { createdAfter: "2026-10-01T00:00:00Z" }) {
    edges {
      cursor
      node { id shopifyOrderId shopifyOrderName type cycleNumber amount currency createdAt subscription { id customerEmail } }
    }
    pageInfo { hasNextPage endCursor }
    totalCount
  }
}
```

* `nodes` is the shortcut when you do not need each row's cursor.
* `totalCount` counts every row matching the filter, not just the page.
* `reverse: true` flips the order (oldest first).
* Nested lists (`subscription.orders`, `customer.subscriptions`) paginate the same way.

***

## Filters

Each list takes a `filter` with the most used fields:

| List | Filters |
| - | - |
| `subscriptions` | `status`, `customerEmail`, `shopifyCustomerId`, `shopifyVariantId`, `planGroupId`, `boxId`, `createdAfter`, `createdBefore`, `updatedAfter`, `nextPaymentAfter`, `nextPaymentBefore`; sort by `CREATED_AT`, `UPDATED_AT` or `NEXT_PAYMENT_DATE` |
| `orders` | `subscriptionId`, `customerEmail`, `type` (`FIRST` / `RENEWAL`), `shopifyOrderId`, `createdAfter`, `createdBefore` |
| `customers` | `emailContains`, `shopifyCustomerId` |
| `planGroups` | `status`, `type` |
| `plans` | `status`, `shopifyVariantId`, `planGroupId` |
| `boxes` | `status`, `type` |

> [!TIP]
>
> For an incremental warehouse sync, request `subscriptions(filter: { updatedAfter: "<last run>" }, sortKey: UPDATED_AT, reverse: true)` and `orders(filter: { createdAfter: "<last run>" })`.

***

## Limits

Limits exist so a heavy integrator can never affect the checkout or the charges. When a query crosses one, the response says **which**:

| Limit | Value | Error code |
| - | - | - |
| Requests per minute, per key | 2000 | `RATE_LIMITED` (HTTP 429, with `Retry-After`) |
| Nested object depth per query | 4 | `QUERY_TOO_DEEP` |
| Nodes per list (`first`) | 100 | `PAGE_TOO_LARGE` |
| Estimated nodes per request (nested lists multiply) | 1000 | `QUERY_TOO_EXPENSIVE` |

Depth counts model objects: `subscriptions › items › plan › group` is four levels. `edges`, `node`, `nodes` and `pageInfo` do not count.

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (epoch seconds) so your client can pace itself.

***

## Errors

Errors follow the GraphQL format, with an `extensions.code` your code can switch on:

```json theme={null}
{ "errors": [ { "message": "Page size limit exceeded: \"subscriptions\" asks for first: 250, the maximum is 100 nodes per connection.", "extensions": { "code": "PAGE_TOO_LARGE" } } ] }
```

| HTTP | Code | When |
| - | - | - |
| 401 | `UNAUTHENTICATED` | Missing, invalid or revoked key |
| 429 | `RATE_LIMITED` | More than 2000 requests in the last minute |
| 400 | `GRAPHQL_PARSE_FAILED`, `GRAPHQL_VALIDATION_FAILED` | The query is not valid GraphQL or asks for fields that do not exist |
| 400 | `QUERY_TOO_DEEP`, `PAGE_TOO_LARGE`, `QUERY_TOO_EXPENSIVE` | See limits |
| 200 | `BAD_USER_INPUT`, `NOT_FOUND` | One field failed (invalid cursor, malformed date); the rest of `data` still arrives |
| 403 | `FORBIDDEN_READ_ONLY` | A mutation with a read-only key |
| 400 | `IDEMPOTENCY_KEY_INVALID` | The `Idempotency-Key` header is empty, longer than 255 characters or has whitespace |
| 422 | `IDEMPOTENCY_KEY_REUSED` | The same `Idempotency-Key` was already used with another body |
| 400 | `BAD_REQUEST` | The body is not JSON shaped as `{ "query": "…", "variables": { } }` |
| 500 | `INTERNAL_ERROR` | Unexpected failure on Reval's side; retry and, if it persists, tell us the time and the query |

Business rules of the actions (invalid state, minimum commitment, archived plan, a Mercado Pago refusal) are **not** errors: they come back in `userErrors` with `200`. See [Actions](#actions).

***

## Actions

Beyond reading, the API lets you **act** on your store's subscriptions with the same actions the merchant has in the admin: pause, resume, cancel, skip the next delivery, change products and quantities, update the address and generate a portal link. It also lets you **build checkout links** with several products and the customer's details already filled in, so a bot can close the sale with a ready link. They are GraphQL **mutations** on the same endpoint.

Every action has exactly the admin's effects: Mercado Pago is updated first (if it refuses, nothing changes in Reval), price and shipping are recomputed when they change, the same events fire to Klaviyo, Flow and your webhooks, and the subscription's timeline names **"API (key name)"** as the author.

### Read-only keys

When you generate a key in **Reval → Integrations** you choose **read and write** or **read-only**. A read-only key can run any query, but every mutation answers `403` with the code `FORBIDDEN_READ_ONLY`. Use it for dashboards and reports; keep read-write keys for the automations that actually act.

### Idempotency

Actions move real charges and shipments, so a retry (a timeout, a crashed worker) must not repeat them. Send an `Idempotency-Key` header with a unique identifier per operation (a UUID). If the same key arrives again with the **same** body within 24 hours, the API returns the original answer without running anything and sets the header `Idempotency-Replayed: true`. If it arrives with a **different** body, it answers `422` with `IDEMPOTENCY_KEY_REUSED`: that is a bug on your side, not a second operation.

```http theme={null}
POST https://api.appreval.com/api/graphql
Authorization: Bearer reval_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Idempotency-Key: 6f1c2a8e-3b7d-4e0f-9a21-5c8d7e6f4a3b
Content-Type: application/json
```

The header is optional, but without it a retry can skip two cycles or cancel twice.

### Business rejections: `userErrors`

A mutation never fails with a GraphQL error for a business rule. It answers `200` with a payload carrying the object **or** a `userErrors` list with a `code` your code can switch on, and the subscription stays as it was:

```json theme={null}
{ "data": { "subscriptionPause": { "subscription": null, "userErrors": [ { "code": "MIN_CYCLES", "message": "The plan has a minimum commitment of 3 cycles and 1 has been charged. Pass overrideMinCycles: true to act as the merchant.", "field": null } ] } } }
```

| Code | When |
| - | - |
| `NOT_FOUND` | No subscription with that id in your store (ids from other stores answer this too) |
| `INVALID_STATE` | The status does not allow the action: pausing a paused one, resuming an active one, editing a cancelled one or one that reached its last cycle |
| `MIN_CYCLES` | The plan has a minimum commitment of cycles not yet met. Same rule the portal applies; `overrideMinCycles: true` acts as the merchant and skips it |
| `SKIP_NOT_ALLOWED` | The store has skips disabled, a skip is already pending or there is no next charge date |
| `PLAN_UNAVAILABLE` | A `planId` does not exist in your store or is archived (plans the subscription already holds may stay) |
| `FREQUENCY_MISMATCH` | A plan bills on another cadence. Mercado Pago mandates keep their frequency |
| `BELOW_MINIMUM` | The new total is below Mercado Pago's minimum charge for the currency |
| `INVALID_INPUT` | Malformed data: empty item list, bad quantity, incomplete address. `field` says which |
| `MERCADO_PAGO_ERROR` | Mercado Pago refused the change. Nothing was applied; retry in a few minutes |
| `REJECTED` | Any other admin rule (for example, boxes are edited in the portal). The `message` explains |

### Available mutations

| Mutation | What it does |
| - | - |
| `subscriptionPause(id, overrideMinCycles)` | Pauses an active subscription. Mercado Pago stops charging until it is resumed |
| `subscriptionResume(id)` | Resumes a paused one. Mercado Pago picks up the schedule and sets the next charge |
| `subscriptionCancel(id, reason, note, overrideMinCycles)` | Cancels with a reason (`TOO_EXPENSIVE`, `PRODUCT_PILED_UP`, `WANT_ANOTHER_PRODUCT`, `DELIVERY_PROBLEMS`, `NO_LONGER_NEEDED`, `OTHER`) and an optional note. Shows in the churn analysis |
| `subscriptionSkipNextCycle(id)` | Skips the next delivery: that cycle is neither charged nor shipped |
| `subscriptionSetItems(id, items)` | Replaces the product lines with the given list (`planId` + `quantity`). Same cadence, active plans. Recomputes total and shipping and updates Mercado Pago |
| `subscriptionUpdateShippingAddress(id, address)` | Replaces the shipping address for the next orders. `address1`, `city` and `zip` are required; shipping is not re-quoted |
| `subscriptionPortalLink(id)` | A signed, expiring link that opens the subscriber's portal on the store's primary domain. Treat it as a credential |
| `checkoutLinkCreate(lines, customer, externalRef)` | Builds a checkout link with several products (each line with its `planId` and quantity, even across different frequencies; or `variantId` only for a one-time purchase) and, optionally, the customer's details prefilled plus a reference of yours that comes back on the subscription and in the webhooks. With customer data the link expires in 30 days and is single-use. See the [bot recipe](https://docs.appreval.com/en/developers/recipes.md#3b-a-bot-that-builds-the-checkout-ready-for-the-customer) |

All return `{ subscription, userErrors }`, except `subscriptionPortalLink` and `checkoutLinkCreate`, which return `{ url, expiresAt, userErrors }`.

**Pause from a support bot**

```graphql theme={null}
mutation PauseSub($id: ID!) {
  subscriptionPause(id: $id) {
    subscription { id status nextPaymentDate }
    userErrors { code message field }
  }
}
```

**Cancel with a reason and a note**

```graphql theme={null}
mutation {
  subscriptionCancel(id: "cm2abc…", reason: OTHER, note: "Moving abroad") {
    subscription { id status statusHistory { toStatus source reason changedAt } }
    userErrors { code message }
  }
}
```

**Change products**

```graphql theme={null}
mutation {
  subscriptionSetItems(id: "cm2abc…", items: [{ planId: "pl_monthly_coffee", quantity: 2 }, { planId: "pl_monthly_filters", quantity: 1 }]) {
    subscription { amount currency items { shopifyVariantId quantity unitAmount } }
    userErrors { code message field }
  }
}
```

**Portal link for a retention email**

```graphql theme={null}
mutation { subscriptionPortalLink(id: "cm2abc…") { url expiresAt userErrors { code } } }
```

***

## Examples

**Active subscriptions with their products and next charge**

```graphql theme={null}
{
  subscriptions(first: 50, filter: { status: [ACTIVE] }, sortKey: NEXT_PAYMENT_DATE, reverse: true) {
    nodes {
      id customerEmail shopifyCustomerId amount currency nextPaymentDate
      frequency frequencyType
      items { shopifyVariantId quantity unitAmount }
      shippingAddress { city province countryCode }
    }
    pageInfo { hasNextPage endCursor }
  }
}
```

**One customer with their whole history**

```graphql theme={null}
{
  customer(email: "ana@example.com") {
    email shopifyCustomerId subscriptionCount activeSubscriptionCount
    subscriptions(first: 20) { nodes { id status createdAt plan { name } } }
    orders(first: 50) { nodes { shopifyOrderName cycleNumber amount createdAt } }
  }
}
```

**This month's cancellations, with reason**

```graphql theme={null}
{
  subscriptions(first: 100, filter: { status: [CANCELLED], updatedAfter: "2026-10-01T00:00:00Z" }) {
    nodes {
      id customerEmail
      statusHistory { fromStatus toStatus source reason changedAt }
    }
  }
}
```

**Published plans and boxes**

```graphql theme={null}
{
  planGroups(filter: { status: ACTIVE }) {
    nodes { id title frequency frequencyType priceType discountPercent amount plans { shopifyVariantId amount } }
  }
  boxes(filter: { status: ACTIVE }) {
    nodes { id title handle type shopifyProductId planGroups { title } options { kind shopifyVariantId } }
  }
}
```


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