# REST API

Base address: `https://regular-subs.fly.dev/api/v1`. Send your key as `Authorization: Bearer <key>`. Responses are JSON and carry an `x-request-id` header.

The machine-readable version is [`/api/v1/openapi.json`](https://regular-subs.fly.dev/api/v1/openapi.json) (OpenAPI 3.1).

Changes have their own endpoints, listed with the reads for the same job. The body is the change's input and path parameters fill in the rest. Add `?preview=true` to get a plan instead of applying, and `X-Change-Reason` to say why (agents must). Every change takes an `Idempotency-Key` header for safe retries.

## Start here

### List the changes a key can make

`GET /api/v1/commands`

Every change this key can make, with its input schema, risk and whether it can be undone.

**Permission:** any key · **Returns:** `{ commands: [{ name, description, risk, reversible, input, http }] }`

### See how subscriptions are doing

`GET /api/v1/overview`

How the store's subscriptions are doing: whether everything is working, active subscribers, monthly recurring revenue, charges due this week, and setup progress. The best first call.

**Permission:** `subscriptions:read` · **Returns:** `{ checks, metrics, trend, forecast, topProducts, activity, setup }`

## Subscriptions

### Find subscriptions

`GET /api/v1/subscriptions`

Find subscriptions, newest first. Filters combine.

**Permission:** `subscriptions:read` · **Returns:** `{ subscriptions: Subscription[], total, nextCursor }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `status` | query | string | Comma-separated: ACTIVE, PAUSED, CANCELLED, EXPIRED, FAILED. |
| `customerId` | query | string | A Shopify customer GID. |
| `productId` | query | string | Has a line for this Shopify product GID. |
| `variantId` | query | string | Has a line for this Shopify variant GID. |
| `sellingPlanId` | query | string | Has a line on this selling plan. |
| `nextBillingFrom` | query | string | Next charge on or after this time (ISO 8601). |
| `nextBillingTo` | query | string | Next charge on or before this time (ISO 8601). |
| `query` | query | string | Product title, SKU, or the end of a contract or customer id. |
| `limit` | query | integer | Results per page, 1–100. |
| `cursor` | query | string | nextCursor from the previous page. |

### Look up a subscription

`GET /api/v1/subscriptions/:id`

One subscription: its status, lines, prices, schedule, next charge and last payment.

**Permission:** `subscriptions:read` · **Returns:** `{ subscription: Subscription }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | Our id, or the Shopify contract GID (URL-encoded). |

### See a subscription's history

`GET /api/v1/subscriptions/:id/history`

What happened to one subscription, newest first, with who did each thing: the customer, staff, an agent, an API key or Shopify.

**Permission:** `subscriptions:read` · **Returns:** `{ subscriptionId, events: [{ at, type, by, to, reason, ... }] }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | Our id, or the Shopify contract GID. |

### Get a link for the customer to manage it

`GET /api/v1/subscriptions/:id/portal-link`

A link that opens this subscription in the customer portal on the store's site, or goes straight to cancelling it. Safe to send: the customer logs in first and only ever sees their own subscriptions.

**Permission:** `subscriptions:read` · **Returns:** `{ subscriptionId, view, url, note }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | Our id, or the Shopify contract GID. |
| `view` | query | string | manage (default) or cancel. |

### Re-check subscriptions against Shopify

`POST /api/v1/subscriptions/reconcile`

Re-check every subscription against Shopify and fix anything out of date. Runs in the background; check progress with get_sync_status. Safe to run any time: it only reads from Shopify and updates our copy.

**Permission:** `subscriptions:sync` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#re-check-subscriptions-against-shopify)

### Skip the next delivery

`POST /api/v1/subscriptions/:subscriptionId/skip`

Skip the next delivery. The next charge moves on by one delivery interval (e.g. a month). Good for customers with too much product who'd otherwise cancel.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** yes · [Input](/docs/reference/commands#skip-the-next-delivery)

### Move the next delivery

`POST /api/v1/subscriptions/:subscriptionId/reschedule`

Move the next delivery to a specific date (tomorrow to a year out). Later deliveries follow on from the new date at the usual interval.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** yes · [Input](/docs/reference/commands#move-the-next-delivery)

### Pause a subscription

`POST /api/v1/subscriptions/:subscriptionId/pause`

Pause a subscription: no charges or deliveries until it's resumed.

**Permission:** `subscriptions:write` · **Risk:** medium · **Undo:** yes · [Input](/docs/reference/commands#pause-a-subscription)

### Resume a subscription

`POST /api/v1/subscriptions/:subscriptionId/resume`

Resume a paused subscription. If its next delivery date has passed, the next delivery is tomorrow at the usual time.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** yes · [Input](/docs/reference/commands#resume-a-subscription)

### Cancel a subscription

`POST /api/v1/subscriptions/:subscriptionId/cancel`

Cancel a subscription. No more charges or deliveries. Record why: it drives which save offers are worth making. Customers can always cancel in one step.

**Permission:** `subscriptions:write` · **Risk:** high · **Undo:** no · [Input](/docs/reference/commands#cancel-a-subscription)

### Change a quantity

`POST /api/v1/subscriptions/:subscriptionId/quantity`

Change how many of an item come in each delivery. The price per item stays the same; the charge changes with the quantity.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** yes · [Input](/docs/reference/commands#change-a-quantity)

### Change how often it delivers

`POST /api/v1/subscriptions/:subscriptionId/frequency`

Change how often deliveries come, to another frequency on the same offer (e.g. every 4 weeks instead of every 2). The next charge date stays; later ones follow the new frequency.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** yes · [Input](/docs/reference/commands#change-how-often-it-delivers)

### Swap to another variant

`POST /api/v1/subscriptions/:subscriptionId/variant`

Switch an item to another variant of the same product (size, flavour, grind). The subscription discount carries over to the new variant's price.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** yes · [Input](/docs/reference/commands#swap-to-another-variant)

### Change the shipping address

`POST /api/v1/subscriptions/:subscriptionId/address`

Ship future deliveries to a new address. The shipping price and method stay as they are, and Shopify refuses an address the store doesn't ship to. The address isn't kept in the activity log.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#change-the-shipping-address)

## Cancellation offers

### Get the offer for a customer who wants to cancel

`GET /api/v1/subscriptions/:id/save-offer`

The one offer for a customer who wants to cancel, for their reason (skip, deliver later, less often, pause, swap, or a discount the store allows). Null when there's nothing worth offering. Share it once; if they say no, cancel.

**Permission:** `subscriptions:read` · **Returns:** `{ offer: { kind, heading, detail, accept, ... } | null }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | Our id, or the Shopify contract GID. |
| `reason` | query | string | too_much, too_expensive, taking_break, not_needed, didnt_like or other. |

### See how cancellation offers are doing

`GET /api/v1/retention`

How cancellation offers are doing: customers shown an offer, how many stayed, the save rate and monthly revenue kept, by reason and by offer.

**Permission:** `subscriptions:read` · **Returns:** `{ days, shown, saved, cancelled, saveRate, revenueKept, byOffer, byReason }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `days` | query | integer | How far back to look. Default 30. |

### Keep a customer with an offer

`POST /api/v1/subscriptions/:subscriptionId/save`

Take the offer shown to a customer who was about to cancel (skip, deliver later, less often, pause, swap, or a discount) instead of cancelling. Get the offer first with get_save_offer for the customer's reason; only that offer can be accepted. Recorded as a save in the retention report.

**Permission:** `subscriptions:write` · **Risk:** medium · **Undo:** no · [Input](/docs/reference/commands#keep-a-customer-with-an-offer)

## Offers

### List offers

`GET /api/v1/offers`

The subscription offers shoppers can choose on product pages: name, frequencies, discount and products.

**Permission:** `offers:read` · **Returns:** `{ offers: Offer[] }`

### Look up an offer

`GET /api/v1/offers/:id`

One offer.

**Permission:** `offers:read` · **Returns:** `{ offer: Offer }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | Our id, or the Shopify selling plan group GID. |

### Create an offer

`POST /api/v1/offers`

Offer subscriptions on products: a name, one or more delivery frequencies, and an optional discount. Shoppers see it on the product page and choose a frequency at checkout.

**Permission:** `offers:write` · **Risk:** medium · **Undo:** yes · [Input](/docs/reference/commands#create-an-offer)

### Change an offer

`PATCH /api/v1/offers/:id`

Change an offer's name, description, delivery frequencies, discount or products. Existing subscriptions keep the terms they started with; changes apply to new subscriptions.

**Permission:** `offers:write` · **Risk:** medium · **Undo:** yes · [Input](/docs/reference/commands#change-an-offer)

### Stop offering a subscription

`DELETE /api/v1/offers/:id`

Stop offering a subscription option. It disappears from product pages. Existing subscriptions keep running on the terms they started with.

**Permission:** `offers:write` · **Risk:** high · **Undo:** no · [Input](/docs/reference/commands#stop-offering-a-subscription)

## Billing

### See how billing is going

`GET /api/v1/billing/summary`

Charges, failed payments, retries and what's due next.

**Permission:** `billing:read` · **Returns:** `{ due, retrying, failed, recent, ... }`

### List charges and payment attempts

`GET /api/v1/billing/cycles`

Billing cycles (one per renewal) with their payment attempts, newest first.

**Permission:** `billing:read` · **Returns:** `{ cycles: Cycle[], nextCursor }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `state` | query | string | Comma-separated: scheduled, charging, retry_scheduled, succeeded, exhausted, skipped, cancelled. |
| `subscriptionId` | query | string | Only this subscription's cycles. |
| `limit` | query | integer | Results per page, 1–100. |
| `cursor` | query | string | nextCursor from the previous page. |

### Email a card update link

`POST /api/v1/subscriptions/:subscriptionId/payment-update`

Email the customer a secure Shopify link to update the card on this subscription. Use it after a declined or expired card, or when a customer asks. At most once an hour per subscription.

**Permission:** `subscriptions:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#email-a-card-update-link)

### Retry a failed payment now

`POST /api/v1/billing/cycles/:cycleId/retry`

Retry a failed charge now instead of waiting for the next scheduled retry, e.g. after the customer says they've updated their card. Charges the customer's payment method.

**Permission:** `billing:write` · **Risk:** financial · **Undo:** no · [Input](/docs/reference/commands#retry-a-failed-payment-now)

## Customer emails

### See which customer emails were sent

`GET /api/v1/notifications`

Customer emails sent, skipped or failed, newest first, with why. Recipients are masked.

**Permission:** `subscriptions:read` · **Returns:** `{ notifications: [...], problems: { missingEmailAccess } }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `subscriptionId` | query | string | Only this subscription's emails. |
| `limit` | query | integer | Results per page, 1–100. |

## Imports

### List imports

`GET /api/v1/imports`

Subscriptions brought in from another app: each upload or fetch, its status and counts.

**Permission:** `imports:read` · **Returns:** `{ imports: Import[] }`

### Check an import

`GET /api/v1/imports/:id`

One import: problems grouped by kind with examples, warnings, a preview of what will be created, failures, and how many are waiting to bill.

**Permission:** `imports:read` · **Returns:** `{ import: ImportDetail }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | The import's id. |

### Upload subscriptions to import

`POST /api/v1/imports`

Upload a subscriptions export from another app (Recharge, Skio, Appstle, Loop, Seal, Shopify Subscriptions or a spreadsheet). Nothing is created yet: each subscription is checked against the store (customer, saved card, products, date) in the background. Read the import to see what's ready and what needs fixing, then run imports.start.

**Permission:** `imports:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#upload-subscriptions-to-import)

### Create the imported subscriptions

`POST /api/v1/imports/:importId/start`

Create the import's ready subscriptions in Shopify, keeping each one's next charge date, price and schedule. They're created in the background, one at a time. They don't bill until imports.release_billing, so the old app has time to be switched off. Subscriptions with problems are left out.

**Permission:** `imports:write` · **Risk:** medium · **Undo:** no · [Input](/docs/reference/commands#create-the-imported-subscriptions)

### Start billing imported subscriptions

`POST /api/v1/imports/:importId/release-billing`

Start billing an import's subscriptions here. Run it only once the old app has stopped charging them (turned off or uninstalled), or customers will be charged twice. Any whose charge date passed while waiting move to their next date, since the old app charged them.

**Permission:** `imports:write` · **Risk:** high · **Undo:** no · [Input](/docs/reference/commands#start-billing-imported-subscriptions)

### Throw away an import

`DELETE /api/v1/imports/:importId`

Throw away an import that hasn't created anything yet, e.g. to upload a corrected file. Imports that have created subscriptions can't be discarded.

**Permission:** `imports:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#throw-away-an-import)

### Connect Recharge

`POST /api/v1/imports/connections`

Connect the app the store is moving from with an API token from it, so subscriptions (and cards held at Stripe, Braintree or Authorize.net) can be fetched directly, and cancelled there when billing switches over. For Recharge the token needs: read_subscriptions, read_customers, read_payment_methods, write_subscriptions. The token is stored encrypted and never shown again.

**Permission:** `imports:write` · **Risk:** medium · **Undo:** no · [Input](/docs/reference/commands#connect-recharge)

### Disconnect Recharge

`DELETE /api/v1/imports/connections/:source`

Remove the stored API token for the app the store moved from. Delete the token in that app too.

**Permission:** `imports:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#disconnect-recharge)

### Fetch subscriptions from Recharge

`POST /api/v1/imports/connections/:source/fetch`

Fetch every active subscription from the connected app (Recharge) and check them against the store, like an upload but always current. Nothing is created yet. Read the import, then run imports.start.

**Permission:** `imports:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#fetch-subscriptions-from-recharge)

## Plans and activity

### Preview a change

`POST /api/v1/plans`

Preview any command without applying it. Returns exactly what would change and whether it needs a person's approval.

**Permission:** any key · **Returns:** `{ status: "planned", plan: Plan }`

Takes an `Idempotency-Key` header for safe retries.

### Check a preview

`GET /api/v1/plans/:id`

A plan: its preview, status (pending, awaiting_approval, applied, rejected, stale, expired) and what to do next.

**Permission:** any key · **Returns:** `{ plan: Plan }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | The plan's id. |

### Apply a preview

`POST /api/v1/plans/:id/apply`

Apply a plan exactly as previewed. Refused if anything changed since it was made.

**Permission:** any key · **Returns:** `{ status: "applied", auditId, diff, result } or { status: "planned" } while it waits for approval`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | The plan's id. |

Takes an `Idempotency-Key` header for safe retries.

### See every change

`GET /api/v1/activity`

Every change ever applied, newest first: what, who, why, and whether it can be undone.

**Permission:** `audit:read` · **Returns:** `{ activity: [...], nextCursor }`

| Parameter | In | Type | Description |
|---|---|---|---|
| `limit` | query | integer | Results per page, 1–100. |
| `before` | query | string | nextCursor from the previous page. |

### Undo a change

`POST /api/v1/activity/:id/undo`

Reverse a change by running its recorded inverse as a new change. Agents get a plan to apply.

**Permission:** any key · **Returns:** `Same as any change: applied, or a plan`

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | The activity entry's id. |

Takes an `Idempotency-Key` header for safe retries.

## Store

### Read store settings

`GET /api/v1/settings`

Store settings: sender name, support email, retry schedule, customer emails, cancellation offers.

**Permission:** `settings:read` · **Returns:** `{ settings: Settings }`

### Check the last re-sync with Shopify

`GET /api/v1/sync-runs`

Recent re-checks of our data against Shopify: when, how many were checked, and how many were out of date.

**Permission:** `subscriptions:read` · **Returns:** `{ runs: [...] }`

### Change store settings

`PATCH /api/v1/settings`

Change store-wide subscription settings: sender name, support email, the failed-payment retry schedule, what happens after the final retry, and which customer emails are sent. Only the fields you pass change.

**Permission:** `settings:write` · **Risk:** medium · **Undo:** yes · [Input](/docs/reference/commands#change-store-settings)

### Connect Klaviyo

`POST /api/v1/integrations/klaviyo`

Send subscription events (started, cancelled, upcoming charge, order placed, payment failed...) to the store's Klaviyo account, for its own flows. The private API key needs: Accounts: Read, Events: Full, Profiles: Full.

**Permission:** `integrations:write` · **Risk:** medium · **Undo:** no · [Input](/docs/reference/commands#connect-klaviyo)

### Disconnect Klaviyo

`DELETE /api/v1/integrations/klaviyo`

Stop sending subscription events to Klaviyo and remove the stored key.

**Permission:** `integrations:write` · **Risk:** low · **Undo:** no · [Input](/docs/reference/commands#disconnect-klaviyo)
