Kaching Subscriptions API Documentation
Create rich subscription flows and streamline store operations on Shopify using our full-featured, enterprise-ready REST APIs.
Prerequisites
Section titled “Prerequisites”- A Shopify store with the Kaching Subscriptions app installed
- A Kaching Subscriptions API key (generated from the app admin)
- The Advanced plan or higher — the API is not available on lower plans, and requests from a shop below it are rejected with
403 Unauthorized
Base URL
Section titled “Base URL”https://subscriptions.kachingappz.app/shop-apiAll responses are JSON.
Authentication
Section titled “Authentication”Every request must include a Bearer token in the Authorization header:
Authorization: Bearer <your-api-token>If the header is missing, malformed, or the token is unknown, the API responds with 401 Unauthorized:
{ "error": "Unauthorized", "message": "Invalid token"}Rate limiting
Section titled “Rate limiting”The API allows 60 requests per 60 seconds per shop. Requests over the limit receive 429 Too Many Requests:
{ "toast": { "message": "Too many requests, please try again later.", "isError": true }}GET /shop-api/analytics is the one exception: it carries its own, tighter
budgets because a single call computes around twenty metrics. See
Caching and rate limits.
List responses also include a throttle field reflecting the remaining Shopify GraphQL budget. When that budget drops below 1000, the API automatically delays the response by ~3 seconds to avoid tripping Shopify-side limits — design your integration to tolerate that latency.
Confirm a token works and learn which shop it belongs to.
Get authenticated shop
Section titled “Get authenticated shop”GET /shop-apiReturns the shop attached to the API token. Useful as a lightweight health check to verify a token is valid.
Example
curl "https://subscriptions.kachingappz.app/shop-api" \ -H "Authorization: Bearer <your-api-token>"Response (200)
{ "shop": "mystore.myshopify.com"}Analytics
Section titled “Analytics”Read the calculated metrics the Kaching admin shows, rather than re-deriving them from contracts and webhooks.
Get analytics
Section titled “Get analytics”GET /shop-api/analyticsReturns the metrics the Kaching admin shows on its Analytics and Billing health pages — MRR, churn rate, retention, subscription counts and payment success rates — for a preset period or a custom day range.
These are the app’s own calculated figures, not raw data to re-derive: the endpoint reads them through the very same services that render those pages, so a number here and the number on the merchant’s screen cannot disagree. See Matching the admin exactly for the three things that most often make them look different.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
period |
string | No | LAST_30_DAYS |
A preset period (below). Ignored when from and to are given. |
from |
string | No | — | First day of a custom range, YYYY-MM-DD, read in tz. Send together with to. |
to |
string | No | — | Last day of the range, inclusive. On or after from, and not after today in tz. |
tz |
string | No | the shop’s | IANA timezone name, e.g. America/New_York. Decides which instants each day covers. |
Accepted period values: TODAY, YESTERDAY, LAST_24_HOURS, LAST_7_DAYS, LAST_30_DAYS,
LAST_90_DAYS, LAST_365_DAYS, LAST_MONTH (previous calendar month), LAST_12_MONTHS,
LAST_YEAR (previous calendar year), ALL_TIME.
There is no maximum range. period=CUSTOM is also accepted alongside from and to, so a URL
copied from the admin’s own address bar works unchanged.
Example
curl -G "https://subscriptions.kachingappz.app/shop-api/analytics" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "from=2026-09-01" \ --data-urlencode "to=2026-09-30" \ --data-urlencode "tz=America/New_York"Response (200)
{ "shop": "example.myshopify.com", "currency": "USD", "timezone": "America/New_York", "period": null, "customRange": { "from": "2026-09-01", "to": "2026-09-30" }, "range": { "from": "2026-09-01T04:00:00.000Z", "to": "2026-10-01T03:59:59.999Z" }, "failedPaymentsCountAsChurn": false, "subscriptions": { "activeCount": 259, "pausedCount": 17, "cancelledCount": 48, "newCount": 296, "churnedCount": 74, "reactivatedCount": 12, "netChange": 211, "churnRate": 24.71, "averageRetentionPeriodDays": 121, "averageActiveSubscriptionAgeDays": 197 }, "revenue": { "mrr": 31916.2, "expectedCollectionsNext30Days": 25626.32, "upcomingRevenue": [{ "date": "2026-10-16", "revenue": 583.85 }], "arpu": 61.33, "averageOrderValue": 48.9, "averageOrdersPerSubscription": 4.2, "averageLifetimeRevenuePerSubscription": 205.4, "firstSubscriptionOrdersCount": 96, "recurringOrdersCount": 1104, "firstSubscriptionOrdersRevenue": 4512.0, "recurringOrdersRevenue": 51993.2, "totalSubscriptionRevenue": 56505.2, "subscriptionTakeRate": 0.31, "subscriptionRevenueShare": 0.72 }, "billingHealth": { "successRate": 94.2, "firstTrySuccessRate": 88.1, "failureRate": 5.8, "perCycleSuccessRate": 97.4, "perCycleFirstTrySuccessRate": 90.2, "isPerCycleRatePreferred": true, "totalBillingAttemptsCount": 1210, "successfulBillingAttemptsCount": 1140, "failedBillingAttemptsCount": 70, "firstTrySuccessCount": 1066, "recoveredBillingAttemptsCount": 52, "recoveredPaymentsValue": 2480.0, "lostBillingAttemptsCount": 18, "successCyclesCount": 1140, "lostCyclesCount": 31, "resolvedCyclesCount": 1171, "subscriptionsInDunningCount": 24, "failureReasons": { "rows": [{ "reason": "insufficient_funds", "count": 31, "percentage": 44.3 }], "truncated": false, "totalDistinctReasons": 6 } }}Top-level fields
Section titled “Top-level fields”| Field | Type | Description |
|---|---|---|
shop |
string | The shop domain the token belongs to. |
currency |
string | The shop’s currency. Every money figure is in it. |
timezone |
string | The zone the window was read in — your tz, or the shop’s. |
period |
string|null | The preset read, or null when a custom range was. |
customRange |
object|null | { from, to } inclusive days, or null when a preset was read. |
range |
object | The instants the window actually covers, inclusive, as ISO-8601 UTC. |
failedPaymentsCountAsChurn |
boolean | The shop’s churn setting — see below. |
subscriptions
Section titled “subscriptions”| Field | Type | Description |
|---|---|---|
activeCount |
number|null | Subscriptions counted as active right now — not over the window. Includes or excludes contracts in dunning per the churn setting. |
pausedCount |
number|null | Contracts currently paused. Now, not over the window. |
cancelledCount |
number|null | Contracts currently cancelled. Now, not over the window. |
newCount |
number|null | Subscriptions that became active during the window, summed per day. One that starts and churns the same day is excluded. |
churnedCount |
number|null | Subscriptions that became inactive during the window, summed per day. One that churns and returns the same day is excluded. |
reactivatedCount |
number|null | Previously inactive subscriptions that became active again during the window. |
netChange |
number|null | Last recorded active count in the window minus the first. Days without a snapshot are skipped, so for a shop younger than the window this spans only the recorded days. |
churnRate |
number|null | Percent 0–100: (starting + new − ending) / (starting + new). null unless the window both begins and ends with a recorded snapshot — see the caution below. |
averageRetentionPeriodDays |
number|null | Average days a subscription stayed active before churning, over those that churned in the window. null when none did. |
averageActiveSubscriptionAgeDays |
number|null | Average age in days of currently-active subscriptions, from creation to today. |
revenue
Section titled “revenue”| Field | Type | Description |
|---|---|---|
mrr |
number|null | Monthly Recurring Revenue: every active contract’s price normalised to a month. Always active-only. |
expectedCollectionsNext30Days |
number|null | Revenue expected from orders scheduled in the next 30 days. Forward-looking: it ignores period. |
upcomingRevenue |
array|null | { date, revenue } per upcoming day — the daily breakdown of the figure above. Also ignores period. |
arpu |
number|null | Average revenue per unique customer from subscription orders in the last 30 days. Ignores period. |
averageOrderValue |
number|null | Average value across all subscription orders, initial and recurring. All time. |
averageOrdersPerSubscription |
number|null | Average successful orders per subscription over its lifetime. All time. |
averageLifetimeRevenuePerSubscription |
number|null | Average total revenue per subscription over its lifetime. All time. |
firstSubscriptionOrdersCount |
number|null | Initial orders from subscriptions created during the window. |
recurringOrdersCount |
number|null | Recurring subscription orders during the window. |
firstSubscriptionOrdersRevenue |
number|null | Revenue from those initial orders. |
recurringOrdersRevenue |
number|null | Revenue from those recurring orders. |
totalSubscriptionRevenue |
number|null | The two above added. Includes tax and shipping, subtracts discounts; refunds are not subtracted. |
subscriptionTakeRate |
number|null | Ratio 0–1: first-subscription-order revenue against that plus one-time revenue. |
subscriptionRevenueShare |
number|null | Ratio 0–1: all subscription revenue against all revenue. |
The last two are the only figures read from Shopify rather than from Kaching’s own records, so they
are also the only ones a Shopify rate limit can turn into null on its own. Every other field in
the response is unaffected when that happens.
billingHealth
Section titled “billingHealth”Two readings of the same window, deliberately both returned.
| Field | Type | Description |
|---|---|---|
successRate |
number|null | Percent 0–100, per attempt: successful billing attempts over all attempts in the window. |
firstTrySuccessRate |
number|null | Percent 0–100: attempts that succeeded first try, over all attempts. |
failureRate |
number|null | Percent 0–100: failed attempts over all attempts. |
perCycleSuccessRate |
number|null | Percent 0–100, per cycle: cycles that ended up paid, over cycles that resolved. Cycles still retrying are in neither. |
perCycleFirstTrySuccessRate |
number|null | Percent 0–100: cycles paid on the first attempt, over resolved cycles. |
isPerCycleRatePreferred |
boolean | Which of the two bases the merchant’s own Billing health page is currently showing. |
totalBillingAttemptsCount |
number|null | Billing attempts in the window. |
successfulBillingAttemptsCount |
number|null | Of those, successful. |
failedBillingAttemptsCount |
number|null | Of those, failed. |
firstTrySuccessCount |
number|null | Of those, successful on the first attempt. |
recoveredBillingAttemptsCount |
number|null | Payments that failed and were later recovered by dunning. |
recoveredPaymentsValue |
number|null | Their total value, in currency. |
lostBillingAttemptsCount |
number|null | Attempts dunning gave up on. |
successCyclesCount |
number|null | Cycles that resolved into a paid order in the window. |
lostCyclesCount |
number|null | Cycles dunning gave up on, deduplicated. |
resolvedCyclesCount |
number|null | successCyclesCount + lostCyclesCount — the per-cycle denominator. |
subscriptionsInDunningCount |
number|null | Subscriptions currently in dunning. Now, not over the window. |
failureReasons |
object|null | { rows: [{ reason, count, percentage }], truncated, totalDistinctReasons } — top 20 reasons by count. |
Why the two rates differ so much. Every dunning retry writes its own failure, so a cycle that
failed four times and then paid contributes one success and four failures to the per-attempt rate.
The harder a shop retries, the worse that rate looks. perCycleSuccessRate counts each cycle once,
and is usually what a merchant means by “how many of my charges go through”.
The failed-payments setting
Section titled “The failed-payments setting”Merchants choose, in the Kaching admin under Settings → General, whether a subscription whose
payment failed and never recovered counts as churned. failedPaymentsCountAsChurn reports that
choice. It cannot be overridden per request — following the saved setting is what keeps these
figures equal to the ones on the merchant’s screen.
| Setting | What counts as a live subscription |
|---|---|
false |
Active and failed. A failed payment is still in dunning and can recover, so it stays alive. |
true |
Active only. A failure never followed by a return to active churns on the day the payment failed. |
Fields that follow it: subscriptions.activeCount, averageActiveSubscriptionAgeDays,
churnRate and netChange.
Fields that never follow it: revenue.mrr (always active-only), subscriptions.newCount,
churnedCount, reactivatedCount, averageRetentionPeriodDays, pausedCount, cancelledCount,
and everything under billingHealth.
Caching and rate limits
Section titled “Caching and rate limits”A successful response is cached for 5 minutes per shop, window, timezone and setting, and says so:
Cache-Control: private, max-age=300X-Kaching-Analytics-Cache: hitChanging the failed-payments setting takes effect immediately — it is part of the cache key, not merely reported in the body.
This endpoint carries its own limits in place of the global 60/60s, because one call computes around twenty separate metrics:
| Bucket | Budget | Counts |
|---|---|---|
| Requests | 30 per 60 seconds | Every request. |
| Cold computations | 10 per 300 seconds | Only requests that miss the 5-minute response cache. |
A job polling one window never touches the second budget — after the first call its requests come
from cache. The second budget bounds a caller walking many different windows. Refusals carry
Retry-After in seconds.
Matching the admin exactly
Section titled “Matching the admin exactly”- Timezone is the usual reason two numbers disagree. The admin’s Analytics page reads the window
in the browser’s timezone; this endpoint defaults to the shop’s. To reproduce a screen
exactly, send the
tzthat browser was in. - Some figures ignore
periodby design.expectedCollectionsNext30DaysandupcomingRevenuelook 30 days forward;arpuis always the last 30 days;averageOrderValue,averageOrdersPerSubscriptionandaverageLifetimeRevenuePerSubscriptionare all-time; andactiveCount,pausedCount,cancelledCountandsubscriptionsInDunningCountare counts of now, not of the window. ALL_TIMEandLAST_24_HOURShave no equivalent in the admin. Its date filter does not offer them, so those two cannot be compared against a screen. Every other period, and any custom range, can.
Validation
Section titled “Validation”Unlike the admin — which quietly falls back to “Last 7 days” for a hand-edited URL — this endpoint always tells you when a parameter is wrong, so an integration never books a week’s figures as the quarter it asked for.
| Request | Response |
|---|---|
from without to (or vice versa) |
400 { "error": "from and to must be given together" } |
to before from |
400 { "error": "The start date must be on or before the end date" } |
| A day that is not a real date | 400 { "error": "Not a real calendar date" } |
to after today in tz |
400 { "error": "to must not be after today (2026-10-15 in UTC)" } |
An unknown period |
400 listing the accepted values |
period=CUSTOM without both days |
400 { "error": "period=CUSTOM needs both from and to" } |
A tz that is not an IANA zone |
400 { "error": "tz must be a valid IANA timezone" } |
A single metric that cannot be read is not an error — it comes back as null with the rest of
the response intact.
Contracts
Section titled “Contracts”Read subscription contracts and change their state.
List contracts
Section titled “List contracts”GET /shop-api/contractsReturns a paginated list of subscription contracts for the authenticated shop.
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
cursor |
string | No | — | Opaque pagination cursor from the previous response’s cursor field. |
query |
string | No | status:ACTIVE OR status:PAUSED OR status:FAILED OR status:CANCELLED |
Shopify GraphQL query syntax, e.g. status:ACTIVE, status:PAUSED. |
email |
string | No | — | Customer email (case-insensitive exact match). Narrows the result to that customer’s contracts. Combines with query via AND. Unknown email returns an empty data array. |
customerId |
string | No | — | Shopify customer ID — either a numeric ID (e.g. 258369147) or a full GID (gid://shopify/Customer/258369147). Narrows the result to that customer’s contracts. Combines with query via AND. Unknown customer returns an empty data array. Takes precedence over email if both are supplied. |
orderId |
string | No | — | Shopify order ID — either a numeric ID (e.g. 369147258) or a full GID (gid://shopify/Order/369147258). Narrows the result to the contract(s) attached to that order. Combines with query via AND. Unknown or non-subscription orders return an empty data array. Takes precedence over customerId and email if multiple are supplied. |
Each page returns up to 50 contracts.
Example — fetch all active contracts
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "query=status:ACTIVE"Example — paginate
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "query=status:ACTIVE" \ --data-urlencode "cursor=eyJsYXN0X2lkIjoi..."Loop until hasNextPage is false, passing the previous response’s cursor on each iteration.
Example — fetch a single contract by ID
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "query=id:123456789"Example — fetch a specific customer’s contracts by email
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "email=sarah@example.com"Combine with query to narrow further, e.g. only that customer’s cancelled contracts:
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "email=sarah@example.com" \ --data-urlencode "query=status:CANCELLED"Example — fetch a specific customer’s contracts by customer ID
Customer ID is preferred over email when available — it’s stable across email changes. Accepts either the numeric ID or the full Shopify GID.
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "customerId=258369147"curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "customerId=gid://shopify/Customer/258369147"Example — fetch a contract by orderId
Use orderId when you have a Shopify order in hand and want to find the subscription contract(s) it was generated by. Accepts either the numeric ID or the full Shopify GID.
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "orderId=369147258"curl -G "https://subscriptions.kachingappz.app/shop-api/contracts" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "orderId=gid://shopify/Order/369147258"If multiple of orderId, customerId, and email are provided, priority is orderId > customerId > email; the lower-priority parameters are ignored.
Response (200)
{ "data": [ { "node": { "id": "gid://shopify/SubscriptionContract/123456789", "status": "ACTIVE", "currencyCode": "USD", "lastPaymentStatus": "SUCCEEDED", "revisionId": "gid://shopify/SubscriptionContractRevision/987654321", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-02-10T14:22:00Z", "deliveryPolicy": { "interval": "MONTH", "intervalCount": 1 }, "billingPolicy": { "interval": "MONTH", "intervalCount": 1, "maxCycles": null, "minCycles": null }, "deliveryPrice": { "amount": "5.99", "currencyCode": "USD" }, "deliveryMethod": { "__typename": "SubscriptionDeliveryMethodShipping", "address": { "countryCodeV2": "US", "city": "San Francisco" } }, "linesCount": { "count": 2 }, "lines": { "edges": [ { "node": { "id": "gid://shopify/SubscriptionLine/111222333", "title": "Product Title", "variantTitle": "Variant Title", "productId": "gid://shopify/Product/444555666", "variantId": "gid://shopify/ProductVariant/777888999", "quantity": 2, "lineDiscountedPrice": { "amount": "47.98", "currencyCode": "USD" }, "pricingPolicy": { "basePrice": { "amount": "25.99", "currencyCode": "USD" }, "cycleDiscounts": [ { "adjustmentType": "PERCENTAGE", "afterCycle": 0, "computedPrice": { "amount": "23.99", "currencyCode": "USD" }, "adjustmentValue": { "percentage": 8.0 } } ] }, "discountAllocations": [] } } ], "pageInfo": { "hasNextPage": false, "hasPreviousPage": false, "endCursor": null, "startCursor": null } }, "discounts": { "edges": [] }, "customer": { "id": "gid://shopify/Customer/258369147", "displayName": "Sarah Johnson", "defaultEmailAddress": { "emailAddress": "sarah@example.com" } }, "orders": { "edges": [{ "node": { "id": "gid://shopify/Order/369147258" } }], "pageInfo": { "hasNextPage": false, "hasPreviousPage": false, "endCursor": null, "startCursor": null } } } } ], "hasNextPage": true, "cursor": "eyJsYXN0X2lkIjoiZ2lkOi8vc2hvcGlmeS...", "throttle": 1850}See the Contract object reference below for field details.
Pause a contract
Section titled “Pause a contract”POST /shop-api/contracts/pauseTransitions an ACTIVE contract to PAUSED. No further billing cycles run until the contract is resumed.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string | Yes | Either a Shopify GID (gid://shopify/SubscriptionContract/123) or the bare numeric ID. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/pause" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{"contractId":"gid://shopify/SubscriptionContract/123456789"}'Response (200)
{ "success": true }Resume a contract
Section titled “Resume a contract”POST /shop-api/contracts/resumeTransitions a PAUSED contract back to ACTIVE. Billing resumes on the contract’s configured schedule.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string | Yes | Shopify GID or numeric ID of the contract. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/resume" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{"contractId":"gid://shopify/SubscriptionContract/123456789"}'Response (200)
{ "success": true }Cancel a contract
Section titled “Cancel a contract”POST /shop-api/contracts/cancelTransitions a contract to CANCELLED.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string | Yes | Shopify GID or numeric ID of the contract. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/cancel" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{"contractId":"gid://shopify/SubscriptionContract/123456789"}'Response (200)
{ "success": true }Reactivate a cancelled contract
Section titled “Reactivate a cancelled contract”POST /shop-api/contracts/reactivateBrings a cancelled subscription back.
The new contract keeps the customer, payment method, lines, prices, discounts, delivery method and billing interval of the old one. Anything a Kaching streak had placed on the old contract (a free gift line, a streak discount) is left behind; the new contract starts earning its own.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string | Yes | The cancelled contract: Shopify GID or numeric ID. |
reactivationDate |
string | No | YYYY-MM-DD in the shop’s timezone — the day the new contract first bills, at 11:00 shop time. Tomorrow to 30 days out. Defaults to tomorrow in the shop’s timezone. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/reactivate" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{ "contractId": "gid://shopify/SubscriptionContract/123456789", "reactivationDate": "2026-10-20" }'Response (200)
{ "success": true, "contract": { "id": "gid://shopify/SubscriptionContract/19001234567", "reactivatedFromId": "gid://shopify/SubscriptionContract/123456789" }}List contracts then shows the new contract as ACTIVE and the old one as
CANCELLED.
A contract gets one successor. A second reactivation of the same contract is refused with 409,
so that a customer is never left paying for two live subscriptions:
{ "error": "Subscription has already been reactivated" }A contract that is not cancelled is refused with 409 too
({ "error": "Subscription is not cancelled" }), and one that cannot be rebuilt as it stands —
no payment method, a revoked payment method, a prepaid plan, a line whose product no longer exists,
or a currency the store no longer sells in — with 422 and a message naming the reason.
Add a discount to a contract
Section titled “Add a discount to a contract”POST /shop-api/contracts/add-discountApplies a discount to every line of a contract. The value is supplied inline, so you do not need to
create a Shopify discount code first. The discount shows up in pricingPolicy.cycleDiscounts and
discountAllocations the next time you list contracts.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string|number | Yes | Shopify GID or numeric ID of the contract. |
discountType |
string | Yes | PERCENTAGE or FIXED_AMOUNT. |
value |
number | Yes | PERCENTAGE: a whole number from 1 to 100. FIXED_AMOUNT: a positive amount, at most 2 decimals, up to 100000. |
title |
string | Yes | 2–255 characters. Shown to the customer on the order. |
recurringCycleLimit |
number | No | How many upcoming orders the discount applies to (1–120). Omit it, or send null, to apply the discount to every future order. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/add-discount" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{ "contractId": "gid://shopify/SubscriptionContract/123456789", "discountType": "PERCENTAGE", "value": 15, "title": "15% off your next 3 orders", "recurringCycleLimit": 3 }'Response (200)
{ "success": true, "discount": { "id": "gid://shopify/SubscriptionManualDiscount/12345678", "title": "15% off your next 3 orders", "type": "PERCENTAGE", "value": 15, "currencyCode": "USD", "recurringCycleLimit": 3 }}Every call applies — repeat calls stack. There is no deduplication: two requests leave two discounts on the contract, whether or not they are identical. Stacking is deliberate, so that you can layer a second offer on a contract that already carries one.
Line items
Section titled “Line items”Change what a subscription contains. These endpoints alter what the customer receives and pays, so read the notes at the end of the group before wiring them up.
Add a line item to a contract
Section titled “Add a line item to a contract”POST /shop-api/contracts/add-line-itemAdds a product variant to a contract. The new line rides the contract’s existing billing schedule
and appears in lines the next time you list contracts.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string|number | Yes | Shopify GID or numeric ID of the contract. |
variantId |
string|number | Yes | Shopify GID (gid://shopify/ProductVariant/123) or numeric ID of the variant to add. |
quantity |
number | Yes | Whole number from 1 to 1000000. |
price |
number | No | Per-unit price in the contract’s currency: 0 or more, at most 2 decimals, up to 1000000. Omit it to price the line at the variant’s price for the contract’s delivery country. |
currencyCode |
string | No | Three-letter currency code. A check, not a conversion: the request is refused if it differs from the contract’s currencyCode. |
How the line is priced
- With
price— the line is contracted at that price on every future order. The contract’s selling plan cannot re-price it later. - Without
price— the line is priced at the variant’s price for the market the contract ships to, in the contract’s currency. If the variant has no price in that currency, the request is refused with422and nothing is added.
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/add-line-item" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{ "contractId": "123456789", "variantId": "44123456789", "quantity": 2, "price": 25.99, "currencyCode": "USD" }'Response (200)
{ "success": true, "line": { "id": "gid://shopify/SubscriptionLine/0f1dcb8f-e608-4c3f-b153-dc77897d39e9", "variantId": "gid://shopify/ProductVariant/44123456789", "quantity": 2, "price": 25.99, "currencyCode": "USD", "priceSource": "REQUEST" }}priceSource is REQUEST when the price is the one you sent, and CONTEXTUAL when it was taken
from the contract’s market.
Update a line item
Section titled “Update a line item”POST /shop-api/contracts/update-line-itemChanges the variant, quantity and/or price of an existing line. Send at least one of variantId,
quantity and price.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string|number | Yes | Shopify GID or numeric ID of the contract. |
lineId |
string | Yes | The line’s id from list contracts — the full gid://shopify/SubscriptionLine/… GID or the part after the last /. |
variantId |
string|number | No | Move the line to this variant. |
quantity |
number | No | New quantity, a whole number from 1 to 1000000. |
price |
number | No | New per-unit price in the contract’s currency, with the same rules as for adding a line. |
currencyCode |
string | No | Checked against the contract’s currency, as for adding a line. |
What each field changes
quantity— only the quantity. The line’s price and discount schedule are left as they are.variantId— the line moves to the new variant at the price it already has. A variant swap never changes what the customer pays; sendpriceas well if it should.price— the line is contracted at the new price on every future order, replacing any discount schedule it had.
Example — swap the variant and change the quantity
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/update-line-item" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{ "contractId": "123456789", "lineId": "gid://shopify/SubscriptionLine/0f1dcb8f-e608-4c3f-b153-dc77897d39e9", "variantId": "44987654321", "quantity": 3 }'Response (200)
{ "success": true, "line": { "id": "gid://shopify/SubscriptionLine/0f1dcb8f-e608-4c3f-b153-dc77897d39e9", "variantId": "gid://shopify/ProductVariant/44987654321", "quantity": 3, "price": 25.99, "currencyCode": "USD", "priceSource": "REQUEST" }, "removedDiscounts": []}Each entry in removedDiscounts is { "discountId": "gid://shopify/SubscriptionManualDiscount/…", "reason": "quantity-invalidated" }.
Remove a line item
Section titled “Remove a line item”POST /shop-api/contracts/remove-line-itemRemoves a line from a contract. The contract’s last line cannot be removed — to stop a subscription, cancel it instead.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string|number | Yes | Shopify GID or numeric ID of the contract. |
lineId |
string | Yes | The line’s id from list contracts. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/remove-line-item" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{ "contractId": "123456789", "lineId": "gid://shopify/SubscriptionLine/0f1dcb8f-e608-4c3f-b153-dc77897d39e9" }'Response (200)
{ "success": true, "lineId": "gid://shopify/SubscriptionLine/0f1dcb8f-e608-4c3f-b153-dc77897d39e9"}Notes on line item changes
Section titled “Notes on line item changes”- Only
ACTIVE,PAUSEDandFAILEDcontracts can be changed. Others are refused with422, and so is a contract with more than 50 line items. - Changes apply to every future order, not just the next one.
- Every call applies — repeat calls are not deduplicated. Two identical
add-line-itemrequests add two lines. A request that timed out may still have been applied, so read list contracts before retrying. - Each call is its own change. To replace one product with another, either swap the variant
with
update-line-item, or add the new line and then remove the old one — and if you do the latter, check the contract afterwards, since a failure in between leaves both lines or neither. - Lines given as a streak reward cannot be changed or removed (
403). - If the contract is being changed by something else at that moment, the request is refused
with
409. Nothing was applied, so retry after a short wait. - The merchant’s contract-update guard applies. If the shop has it turned on, a change that
takes the contract’s total above both $100 USD and twice the value of its first order is refused
with
422. - Every change appears on the contract’s timeline in the Kaching admin as a “via API” entry.
Billing cycles
Section titled “Billing cycles”Read and adjust when a contract is charged, one cycle at a time.
Get a contract’s billing cycles
Section titled “Get a contract’s billing cycles”GET /shop-api/contracts/billing-cyclesReturns the billing cycles of a single contract, including the cycleIndex that the skip, unskip
and reschedule endpoints below take. A billing cycle is the unit a customer is charged on: it
has a billing date, a window that date must stay inside, a status, and a skipped flag.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
contractId |
string|number | Yes | — | Shopify GID or numeric ID of the contract. |
first |
number | No | 10 | How many cycles to return, 1–10. |
fromCycleIndex |
number | No | — | Start of an explicit cycle range. Must be sent together with toCycleIndex. |
toCycleIndex |
number | No | — | End of that range, inclusive. Must be greater than or equal to fromCycleIndex. |
Without a range, the window starts at now in the shop’s timezone and runs forward, so only
upcoming cycles are returned. Supply the range to see cycles that have already been billed, are
already skipped, or whose billing date has passed. A range reaching past the contract’s last cycle
is refused with 404 rather than truncated, so ask for one you know exists.
Example
curl -G "https://subscriptions.kachingappz.app/shop-api/contracts/billing-cycles" \ -H "Authorization: Bearer <your-api-token>" \ --data-urlencode "contractId=123456789" \ --data-urlencode "first=3"Response (200)
{ "contractId": "gid://shopify/SubscriptionContract/123456789", "subscriptionContractStatus": "ACTIVE", "billingCycles": [ { "cycleIndex": 4, "billingAttemptExpectedDate": "2026-02-15T10:00:00Z", "cycleStartAt": "2026-01-15T00:00:00Z", "cycleEndAt": "2026-02-15T00:00:00Z", "skipped": false, "status": "UNBILLED", "subscriptionContractStatus": "ACTIVE", "billingAttempts": { "nodes": [] }, "sourceContract": { "deliveryPolicy": { "interval": "MONTH", "intervalCount": 1 }, "lines": { "nodes": [] } } } ]}subscriptionContractStatus is the contract’s status; status on each cycle is the cycle’s
(UNBILLED, BILLED or CANCELLED). A cancelled contract still reports projected UNBILLED
cycles, so check the contract’s status before reading the list as a schedule.
Skip a billing cycle
Section titled “Skip a billing cycle”POST /shop-api/contracts/billing-cycles/skipSkips one upcoming cycle: the customer is not charged and no order is created for it. The schedule is not delayed — every later cycle keeps its own date. To move a delivery rather than drop it, reschedule it instead.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string|number | Yes | Shopify GID or numeric ID of the contract. |
cycleIndex |
number | Yes | The cycle to skip, from get billing cycles. |
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/billing-cycles/skip" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{"contractId":"123456789","cycleIndex":4}'Response (200)
{ "success": true, "cycleIndex": 4, "skipped": true, "changed": true, "billingAttemptExpectedDate": "2026-02-15T10:00:00Z"}A cycle that is already skipped is not an error: the response is 200 with "changed": false,
and nothing is sent to Shopify. The endpoint is safe to repeat.
Unskip a billing cycle
Section titled “Unskip a billing cycle”POST /shop-api/contracts/billing-cycles/unskipPuts a skipped cycle back on the schedule, so the customer is charged for it again. Same request body as skip.
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/billing-cycles/unskip" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{"contractId":"123456789","cycleIndex":4}'Response (200)
{ "success": true, "cycleIndex": 4, "skipped": false, "changed": true, "billingAttemptExpectedDate": "2026-02-15T10:00:00Z"}A cycle that is not skipped returns 200 with "changed": false.
Reschedule a billing cycle
Section titled “Reschedule a billing cycle”POST /shop-api/contracts/billing-cycles/rescheduleMoves one cycle’s billing date. Only that cycle moves — later cycles keep their dates, so this is a one-off shift rather than a change of cadence. Call it once per cycle to move several.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contractId |
string|number | Yes | Shopify GID or numeric ID of the contract. |
cycleIndex |
number | Yes | The cycle to move. |
billingDate |
string | Yes | YYYY-MM-DD, or a full ISO 8601 date-time. A bare date is read at the start of that day in the shop’s timezone. |
billingDate must be later than today in the shop’s timezone, and no other cycle of the same
contract may already sit on that day.
Example
curl -X POST "https://subscriptions.kachingappz.app/shop-api/contracts/billing-cycles/reschedule" \ -H "Authorization: Bearer <your-api-token>" \ -H "Content-Type: application/json" \ -d '{"contractId":"123456789","cycleIndex":4,"billingDate":"2026-03-02"}'Response (200)
{ "success": true, "cycleIndex": 4, "changed": true, "billingAttemptExpectedDate": "2026-03-02T00:00:00-05:00"}The applied date can differ from the one you asked for. Shopify validates billingDate against
the cycle’s own window, and derives that window from the previous cycle’s current billing date.
Month-end anchoring is the usual cause: re-anchoring a monthly contract to the 30th puts February on
the 28th, which shortens the window. When the date has to be adjusted, the response carries both:
{ "success": true, "cycleIndex": 4, "changed": true, "billingAttemptExpectedDate": "2026-02-28T00:00:00-05:00", "requestedBillingDate": "2026-03-02T00:00:00-05:00"}billingAttemptExpectedDate is always the date that took effect — show that one to operators.
requestedBillingDate is present only when the two differ.
Notes on billing cycle changes
Section titled “Notes on billing cycle changes”cycleIndexis 1-based, and there is no cycle 0. Cycle 1 is the first recurring charge: its window opens when the contract is created and it bills one billing interval later. The signup/checkout order is not a billing cycle, so the Nth recurring charge is cycle N. Always take the number from get billing cycles rather than computing it.- Only
ACTIVE,PAUSEDandFAILEDcontracts can be changed (422otherwise).PAUSEDis allowed on purpose: a paused contract still has projected cycles, and the date it would bill on remains meaningful. - A cycle that has already been billed cannot be changed (
409), and neither can one whose billing is already in progress. - Unskip before rescheduling. A skipped cycle has no billing date to move, so a reschedule of
one is refused with
409. - Skipping and unskipping are safe to repeat; rescheduling is too, since it sets an absolute date rather than shifting by an offset.
- Scheduled charges follow the change. A charge already queued for a cycle you skip is abandoned, and one queued for a cycle you move is re-queued for the new date — so neither a double charge nor a missed charge results.
- Every change appears on the contract’s timeline in the Kaching admin.
Contract object reference
Section titled “Contract object reference”Top-level contract fields
Section titled “Top-level contract fields”| Field | Type | Description |
|---|---|---|
id |
string | Shopify GID for the contract. |
status |
string | One of ACTIVE, PAUSED, FAILED, EXPIRED, CANCELLED, STALE. |
currencyCode |
string | ISO currency code for the contract. |
lastPaymentStatus |
string | SUCCEEDED or FAILED. |
revisionId |
string | Opaque identifier for the current revision. Use to detect contract changes. |
createdAt |
string | ISO-8601 timestamp. |
updatedAt |
string | ISO-8601 timestamp. |
deliveryPolicy |
object | { interval, intervalCount } — how often deliveries happen. |
billingPolicy |
object | { interval, intervalCount, maxCycles, minCycles } — billing cadence and limits. |
deliveryPrice |
object | { amount, currencyCode } — shipping/delivery charge per cycle. |
deliveryMethod |
object | Delivery method, e.g. SubscriptionDeliveryMethodShipping with an address. |
linesCount |
object | { count } — total number of line items on the contract. |
lines |
object | Connection of line items — see below. |
discounts |
object | Connection of contract-level discount allocations. |
customer |
object | { id, displayName, defaultEmailAddress.emailAddress }. |
orders |
object | Connection of orders generated by this contract. |
interval values: DAY, WEEK, MONTH, YEAR.
Line item fields (lines.edges[].node)
Section titled “Line item fields (lines.edges[].node)”| Field | Type | Description |
|---|---|---|
id |
string | Shopify GID for the line. |
title |
string | Product title. |
variantTitle |
string | Variant title. |
productId |
string | Shopify product GID. |
variantId |
string | Shopify variant GID. |
quantity |
number | Number of units per delivery. |
lineDiscountedPrice |
object | { amount, currencyCode } — final per-line price after discounts. |
pricingPolicy |
object | { basePrice, cycleDiscounts[] } — base price and per-cycle adjustments. |
discountAllocations |
array | Line-level discount allocations — see the note below. |
Inside discountAllocations[].discount, the type field is Shopify’s DiscountType — MANUAL,
CODE_DISCOUNT or AUTOMATIC_DISCOUNT — describing how the discount was applied. It is not
the percentage/fixed-amount distinction. For that, read discount.value ({ percentage } or
{ amount, appliesOnEachItem }), or pricingPolicy.cycleDiscounts[].adjustmentType.
Cycle discount fields (pricingPolicy.cycleDiscounts[])
Section titled “Cycle discount fields (pricingPolicy.cycleDiscounts[])”| Field | Type | Description |
|---|---|---|
adjustmentType |
string | PERCENTAGE, FIXED_AMOUNT, or PRICE. |
afterCycle |
number | Cycle index after which the discount applies (0 = starting with cycle 1). |
computedPrice |
object | { amount, currencyCode } — resolved price once the discount applies. |
adjustmentValue |
object | Either { percentage } or { amount, currencyCode }, depending on type. |
Pagination (pageInfo)
Section titled “Pagination (pageInfo)”Connections (lines, orders, top-level list) return pageInfo:
| Field | Type | Description |
|---|---|---|
hasNextPage |
boolean | true if more pages exist. |
hasPreviousPage |
boolean | true if previous pages exist. |
endCursor |
string | Cursor for the last item in the current page. |
startCursor |
string | Cursor for the first item in the current page. |
Errors
Section titled “Errors”| Status | When | Example body |
|---|---|---|
| 400 | Missing or malformed request body (e.g. no contractId) or invalid query parameter (e.g. email is not a valid address, or an analytics date range that is reversed or not a real date) |
{ "error": "Missing contractId" } |
| 401 | Missing, malformed, or unknown Bearer token | { "error": "Unauthorized", "message": "Invalid token" } |
| 403 | The shop is not on the Advanced plan or higher | { "error": "Unauthorized" } |
| 403 | The line is a streak reward (line item endpoints) | { "error": "This line item is a streak reward and cannot be changed" } |
| 404 | No contract with that ID on this shop, no such line on the contract, or no such variant on this shop | { "error": "Line item not found on this subscription contract" } |
| 409 | The contract is being changed by something else; nothing was applied (line item endpoints) | { "error": "The subscription is being updated, please try again" } |
| 409 | The billing cycle’s state or the requested date forbids the change (billing cycle endpoints) | { "error": "This billing cycle has already been billed" } |
| 409 | The contract is not cancelled, or has already been reactivated (reactivate) |
{ "error": "Subscription has already been reactivated" } |
| 422 | Shopify rejected the change, or the contract cannot be modified (add-discount, line item endpoints) |
{ "error": "Cannot remove the last line item of a subscription contract" } |
| 422 | The contract cannot be reactivated as it stands (reactivate) |
{ "error": "Payment method has been revoked. Please add new payment method" } |
| 429 | Rate limit exceeded (60 requests / 60 seconds per shop; different for analytics) |
{ "toast": { "message": "Too many requests, please try again later.", "isError": true } } |
| 500 | Upstream Shopify error or mutation failure | { "error": "Failed to pause subscription contract" } |
| 503 | The contract’s status could not be read from Shopify. Nothing was changed, so the request can be retried | { "error": "Couldn't read the subscription's status from Shopify, nothing was changed. Please try again" } |
Mutation endpoints (pause, resume, cancel, reactivate, add-discount, add-line-item, update-line-item, remove-line-item, and the billing-cycles/skip, billing-cycles/unskip and billing-cycles/reschedule endpoints) surface Shopify-side validation messages in the error field when Shopify returns userErrors, so prefer showing the raw message to operators rather than masking it.