Quickstart
Read and change your subscriptions from your own systems, and get a signed message whenever one changes. Built for headless storefronts, ERPs, warehouse tools and anything else that should not need someone to open the app.
- Ask for access. The API is switched on store by store while it is new. If Settings > Integrations > API in the app says to contact support, message us in the app's live chat and we will turn it on.
- Create a token. Open Settings > Integrations > API, name the token after whatever will use it, tick what it may do (see scopes) and click Create token. The token is shown once, so copy it straight away.
- Make a call. Send the token as a bearer token. The example on the right checks it works.
Server side only. Never put a token in a theme, a browser or a mobile app. Anyone who finds it can change your subscriptions. No CORS headers are sent, on purpose.
curl "https://subscriptions.super-simple.co/api/v1/ping" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/ping", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/ping",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"ok": true,
"shop_domain": "your-store.myshopify.com",
"token_name": "ERP sync",
"scopes": ["subscriptions:read", "subscriptions:write"]
}Authentication
Send your token in the Authorization header of every request. A token belongs to one store and can only ever see that store's subscriptions. A subscription id from another store behaves exactly like one that does not exist.
A missing, wrong, revoked or expired token all get the same 401, so guesses cannot tell which tokens are real.
Give each system its own token so you can revoke one without touching the rest. Revoke on the settings page stops a token on its next request. We never store the secret, so a lost token cannot be shown again: revoke it and create a new one.
Authorization: Bearer sss_live_<tokenId>_<secret>Scopes
A token carries only the permissions you ticked. A call that needs one the token lacks gets 403 forbidden.
| Scope | Allows |
|---|---|
subscriptions:read | Listing and fetching subscriptions |
subscriptions:write | Pause, resume, cancel, skip, reschedule, change plan and products |
webhooks:read | Listing endpoints, topics, samples and deliveries |
webhooks:write | Creating, changing, deleting and testing endpoints |
Conventions
- JSON. Send
Content-Type: application/jsonon bodies. Responses are JSON withCache-Control: no-store. - snake_case keys. Dates are ISO 8601 in UTC. Money is a string amount with a
currency_code. - Ids are numbers as strings, with the Shopify GraphQL id alongside as
admin_graphql_api_id. Wherever an id goes in a path or body, send the bare number or the full GID. - Every change replies with the subscription as it now stands, so you never need a read straight after a write.
- The shape only grows. We add fields, we do not rename or remove them, so ignore keys you do not recognise.
- Request ids. Every response carries
X-Request-Id. Quote it to support and we can find that exact call.
Pagination
Lists return up to 100 items. Pass the next_page_info from one response as page_info on the next. When has_next_page is false you have everything.
{
"subscriptions": [ ... ],
"page_info": {
"has_next_page": true,
"next_page_info": "eyJsYXN0X2lkIjo..."
}
}Rate limits
Each token has a short burst allowance (per 10 seconds) and a daily one. All of a store's tokens also share a ceiling, because every call spends your store's Shopify allowance. Every store starts on the standard allowance, shown when you create a token. Ask us if your integration needs more.
| Tier | Per 10 seconds | Per day |
|---|---|---|
| basic | 10 | 200 |
| standard | 20 | 500 |
| high | 40 | 1,000 |
| All of a store's tokens, together | 60 | 3,000 |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, describing whichever limit you will hit first. Going over returns 429 rate_limited with a Retry-After header in seconds. Wait that long, then retry.
Prefer webhooks to polling. A subscription that changes tells you, and costs you no calls.
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1758300010
X-Request-Id: 3f2b8c1e-6a4d-4e0b-9c7a-1d5e8f2a9b34Retry-After: 6
{
"error": {
"code": "rate_limited",
"message": "This token has used its API allowance. Retry after the reset, or use a higher tier."
}
}Retrying safely
If a request times out you cannot know whether it happened. Add an Idempotency-Key header (any unique string up to 255 characters, a UUID is ideal) to anything that changes a subscription. A retry with the same key returns the first answer instead of doing the work twice, with X-SSS-Idempotent-Replayed: true.
| Situation | Answer |
|---|---|
| First use of the key | The change runs |
| Same key, same request | The stored response |
| Same key, different request | 422. We refuse rather than let you believe the second change happened |
| Same key, first still running | 409. Retry shortly |
Keys are per token and last 24 hours. A 5xx answer is not remembered, so retrying after our error runs the change for real. The header is ignored on GET.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/pause" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f"Errors
Every error has the same shape, with a stable code to switch on and a message for people.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | A body, id or filter is missing or malformed. The message says which. |
| 401 | unauthorized | The token is missing, wrong, revoked or expired. |
| 403 | forbidden | The token lacks the scope, or the API is not on for this store. |
| 404 | not_found | No such subscription, line, endpoint or path. |
| 405 | method_not_allowed | That path does not take that HTTP method. |
| 409 | conflict | A state the request cannot have, for example skipping an order that is already skipped. |
| 429 | rate_limited | Slow down. See Retry-After. |
| 500 | internal_error | Our side, or Shopify's. Safe to retry with an idempotency key. |
{
"error": {
"code": "not_found",
"message": "No subscription with that id."
}
}The subscription object
Every subscription endpoint, and every webhook, returns subscriptions in this shape.
statusstringACTIVE,PAUSED,CANCELLED,EXPIREDorFAILED. Shopify's own values.next_billing_dateISO 8601When the next order is due.
nullwhen there is none.lines[].line_discounted_pricemoneyThe whole line after discounts, not per unit.
lines[].custom_attributesarrayProperties the shopper or you set on the line. The app's own bookkeeping keys are left out.
lines[].bundleobject or null{ "name", "role" }whereroleis"buy"for a qualifying item or"reward"for the discounted or free one.bundles[]arrayThe same grouping as a list:
{ "name", "line_ids", "reward_line_ids" }, so you can act on a whole bundle without matching lines yourself.appobjectWhat the app knows that Shopify does not: renewals completed, whether the next order is skipped, pause history, the scheduled resume date, and Shopify's projected
next_ordertotals (nulluntil one has been calculated).
{
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"created_at": "2026-08-01T09:30:00.000Z",
"next_billing_date": "2026-11-01T09:30:00.000Z",
"currency_code": "USD",
"customer": {
"id": "2000002",
"admin_graphql_api_id": "gid://shopify/Customer/2000002",
"email": "customer@example.com",
"display_name": "Sample Customer",
"locale": "en"
},
"note": null,
"discounts": [
{
"id": "3000003",
"title": "10% off for 3 orders",
"type": "MANUAL",
"target_type": "LINE_ITEM",
"recurring_cycle_limit": 3,
"value": { "type": "percentage", "percentage": 10 }
}
],
"delivery_price": { "amount": "4.00", "currency_code": "USD" },
"billing_policy": {
"interval": "MONTH",
"interval_count": 1,
"min_cycles": null,
"max_cycles": null
},
"delivery_policy": { "interval": "MONTH", "interval_count": 1 },
"lines": [
{
"id": "4000004",
"admin_graphql_api_id": "gid://shopify/SubscriptionLine/4000004",
"title": "House blend",
"variant_title": "250g",
"variant_id": "5000005",
"product_id": "6000006",
"quantity": 1,
"selling_plan_id": "7000007",
"selling_plan_name": "Every month",
"current_price": { "amount": "18.00", "currency_code": "USD" },
"line_discounted_price": { "amount": "16.20", "currency_code": "USD" },
"custom_attributes": [{ "key": "Gift message", "value": "Enjoy!" }],
"bundle": null
}
],
"bundles": [],
"app": {
"renewals": 2,
"next_order_skipped": false,
"pause_count": 0,
"paused_at": null,
"cancelled_at": null,
"scheduled_resume_date": null,
"billing_attempts": 0,
"next_order": null
}
}List subscriptions
/subscriptionsNeeds the subscriptions:read scope.
Returns your subscriptions, newest first, in pages of up to 100.
Query parameters
limitinteger1 to 100. Default 25.
page_infostringThe
next_page_infofrom the previous page.statusstringOne or more of
ACTIVE,PAUSED,CANCELLED,EXPIRED,FAILED, comma separated.customer_idstringA Shopify customer id.
customer_emailstringAn exact, case insensitive match. Nobody with that address is an empty list, not an error. Cannot be combined with
customer_id. Costs one extra Shopify call.created_at_minISO 8601Only subscriptions created at or after this time.
created_at_maxISO 8601Only subscriptions created at or before this time.
Only filters Shopify can answer directly are offered. That keeps a scripted integration from using up your store's Shopify allowance.
curl "https://subscriptions.super-simple.co/api/v1/subscriptions?status=ACTIVE,PAUSED&limit=50" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions?status=ACTIVE,PAUSED&limit=50", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/subscriptions",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
params={"status": "ACTIVE,PAUSED", "limit": "50"},
)
data = res.json(){
"subscriptions": [
{ "id": "1000001", "status": "ACTIVE", ... }
],
"page_info": {
"has_next_page": true,
"next_page_info": "eyJsYXN0X2lkIjo..."
}
}Get a subscription
/subscriptions/:idNeeds the subscriptions:read scope.
Returns one subscription. An id that belongs to another store returns 404, the same as one that does not exist.
curl "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Pause a subscription
/subscriptions/:id/pauseNeeds the subscriptions:write scope.
Pauses billing. No orders are created until it is resumed.
Body parameters
reasonstringKept in the subscription's timeline.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/pause" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"reason":"Customer is travelling"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/pause", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"reason": "Customer is travelling"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/pause",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"reason": "Customer is travelling"
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Resume a subscription
/subscriptions/:id/resumeNeeds the subscriptions:write scope.
Resumes a paused subscription. It never charges at once. Without a date, a billing date that has already passed moves to the next cycle and any other date is kept.
Body parameters
next_billing_dateISO 8601When the next order should be created. Must be in the future.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/resume" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"next_billing_date":"2026-12-01T09:00:00Z"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/resume", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"next_billing_date": "2026-12-01T09:00:00Z"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/resume",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"next_billing_date": "2026-12-01T09:00:00Z"
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Cancel a subscription
/subscriptions/:id/cancelNeeds the subscriptions:write scope.
Cancels the subscription. This cannot be undone through the API.
Body parameters
reasonstringKept in the subscription's timeline.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/cancel" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"reason":"Requested by email"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"reason": "Requested by email"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/cancel",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"reason": "Requested by email"
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Skip the next order
/subscriptions/:id/skipNeeds the subscriptions:write scope.
Skips the next order. Takes no body. Skipping an order that is already skipped returns 409 conflict.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/skip" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f"const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/skip", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/skip",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Bring back a skipped order
/subscriptions/:id/unskipNeeds the subscriptions:write scope.
Undoes a skip. Takes no body. Returns 409 conflict if the next order is not skipped.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/unskip" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f"const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/unskip", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/unskip",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Change the next billing date
/subscriptions/:id/rescheduleNeeds the subscriptions:write scope.
Moves the next order to another date and time.
Body parameters
next_billing_dateISO 8601requiredThe new date and time of the next order.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/reschedule" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"next_billing_date":"2026-11-15T09:00:00Z"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/reschedule", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"next_billing_date": "2026-11-15T09:00:00Z"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/reschedule",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"next_billing_date": "2026-11-15T09:00:00Z"
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Change the delivery frequency
/subscriptions/:id/planNeeds the subscriptions:write scope.
Moves the subscription to another selling plan. A plan the merchant has switched off for the API (Offers, the option, Where it is offered) is refused with 400, and so is a plan whose bulk minimum the current quantities do not meet.
Body parameters
selling_plan_idstringrequiredThe selling plan to move to. The current one is on
lines[].selling_plan_id.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/plan" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"selling_plan_id":"7000008"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/plan", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"selling_plan_id": "7000008"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/plan",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"selling_plan_id": "7000008"
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Add products
/subscriptions/:id/linesNeeds the subscriptions:write scope.
Adds products to the subscription.
Body parameters
variant_idsarray of idsrequiredProduct variant ids to add.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X POST "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"variant_ids":["5000005","5000006"]}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"variant_ids": [
"5000005",
"5000006"
]
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"variant_ids": [
"5000005",
"5000006"
]
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Change a product
/subscriptions/:id/lines/:lineIdNeeds the subscriptions:write scope.
Changes one line. Send quantity or variant_id, never both. To swap the product and change the quantity, make two calls: swap first, then set the quantity.
Body parameters
quantityintegerThe new quantity. A whole number of 1 or more.
variant_idstringSwap this line to another variant.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X PATCH "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines/4000004" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f" \
-d '{"quantity":3}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines/4000004", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
body: JSON.stringify({
"quantity": 3
}),
});
const data = await res.json();import os, requests
res = requests.patch(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines/4000004",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
"Idempotency-Key": "9f1c2a7e-4b0d-4f2a-9c3e-8a1b2c3d4e5f",
},
json={
"quantity": 3
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Remove a product
/subscriptions/:id/lines/:lineIdNeeds the subscriptions:write scope.
Removes a line. No body.
Accepts an Idempotency-Key header. Answers with the subscription as it now stands.
curl -X DELETE "https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines/4000004" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines/4000004", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.delete(
"https://subscriptions.super-simple.co/api/v1/subscriptions/1000001/lines/4000004",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"subscription": {
"id": "1000001",
"admin_graphql_api_id": "gid://shopify/SubscriptionContract/1000001",
"status": "ACTIVE",
"next_billing_date": "2026-11-01T09:30:00.000Z",
...
}
}Webhooks
Rather than asking us what changed, have us tell you. Register an HTTPS URL and the topics you care about, and we send a signed POST each time one happens. The body contains the subscription exactly as the API returns it.
- Your URL must use
httpson port 443 and be reachable from the internet. Addresses that point inside a network (private ranges,localhost,.local) are refused, and redirects are not followed. - Delivery is at least once. The same event can arrive twice, so ignore a repeated
X-SSS-Delivery-Id. - Answer with any
2xxquickly and do your work afterwards. Anything else counts as a failure and is retried.
Set endpoints up in Settings > Integrations > API in the app, or through the endpoints below. Send yourself a test event before real ones flow.
The payload
Every delivery has the same envelope, plus an object per topic under data. The envelope only grows, so ignore keys you do not know.
occurred_at is when the event happened. state_as_of is when we read the subscription. A pause that was resumed a minute later is still delivered as a pause, with a subscription that reads active. Use event for what changed and data.subscription for what is true now. created_at equals occurred_at and is kept for older integrations.
event.initiator says who made the change, for example customer_portal, shopify_admin or api. previous_status and new_status appear on status changes.
Headers
X-SSS-TopicThe topic, for example
subscription.paused.X-SSS-Shop-DomainYour
myshopify.comdomain.X-SSS-Delivery-IdUnique per delivery. Use it to ignore repeats.
X-SSS-Event-IdThe event this delivery carries.
X-SSS-TimestampUnix seconds when we signed it.
X-SSS-Hmac-SHA256The base64 signature. See verifying.
{
"id": "evt_...",
"topic": "subscription.paused",
"created_at": "2026-10-30T12:00:00.000Z",
"occurred_at": "2026-10-30T12:00:00.000Z",
"state_as_of": "2026-10-30T12:00:07.000Z",
"shop_domain": "your-store.myshopify.com",
"api_version": "v1",
"event": {
"initiator": "customer_portal",
"reason": { "key": "too_many", "text": "I have too much at home" },
"previous_status": "ACTIVE",
"new_status": "PAUSED"
},
"data": {
"subscription": { "id": "1000001", "status": "PAUSED", ... }
}
}Topics
Topic names never change once published, so it is safe to switch on them in your code. Fetch the live list from GET /webhook_topics and a real example of any from GET /webhook_topics/:topic/sample.
| Topic | Sent when |
|---|---|
subscription.created | A subscription is created |
subscription.updated | A subscription is changed in some other way |
subscription.paused | It is paused |
subscription.resumed | It is resumed, by hand or automatically |
subscription.cancelled | It is cancelled, by hand or automatically |
subscription.failed | It failed after its payment retries ran out |
subscription.completed | It reached its last order and expired |
subscription.renewed | A renewal order was created |
subscription.billing_retry_scheduled | A payment was declined and another attempt is scheduled |
subscription.renewal_upcoming | A renewal is coming up, at the customer reminder's lead time. data.renewal has next_billing_date and days_until_renewal |
subscription.next_billing_date_changed | The next billing date moved |
subscription.order_skipped | An order was skipped |
subscription.order_unskipped | A skipped order was brought back |
subscription.line_changed | A product was added, removed, swapped or its quantity changed |
subscription.plan_changed | The delivery frequency changed |
subscription.price_changed | Product prices were updated. data.price_change.lines[] lists the lines |
subscription.payment_method_updated | The payment method was replaced |
subscription.shipping_address_updated | The shipping address changed |
subscription.pause_reason_collected | A customer gave a reason for pausing. data.pause has reason and comment |
retention.offer_presented | A customer was shown a retention offer. data.retention has incentive_type, incentive_value, offered_during |
retention.offer_accepted | They accepted it (same fields) |
retention.offer_declined | They declined it. data.retention.reason |
retention.incentive_applied | An accepted incentive was applied. Adds recurring_cycle_limit |
loyalty.reward_applied | A loyalty reward was applied. data.loyalty has cycle and reward_types[] |
journey.step_applied | A journey step changed a subscription after an order. data.journey has id, name, order_number, steps[] |
notification.sent | The app sent a customer a notification. data.notification has type and channel. The address is never included |
There is no topic for a single failed payment attempt. A failure shows up as subscription.billing_retry_scheduled and, if the retries run out, subscription.failed.
Verifying signatures
Anyone who knows your URL can post to it, so check every delivery really came from us before you act on it. The signature is the base64 HMAC SHA256 of timestamp + "." + raw body, keyed with your endpoint's secret.
- The key is the whole secret as we showed it, including the
sss_whsec_prefix. Do not strip or decode it. - Use the raw body exactly as received. Re-encoding parsed JSON changes the bytes and the signature will not match.
- Reject any delivery whose timestamp is more than five minutes from your clock. The timestamp is signed, so this stops a captured delivery being replayed.
- Compare in constant time.
# Reproduce a signature by hand to debug a mismatch.
# SECRET is the whole string, including the sss_whsec_ prefix.
printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| base64import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SUPR_WEBHOOK_SECRET; // sss_whsec_...
const app = express();
// Keep the raw bytes: express.json() would re-encode them.
app.post("/hooks/supr", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("x-sss-timestamp");
const signature = req.get("x-sss-hmac-sha256") ?? "";
const raw = req.body.toString("utf8");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${raw}`)
.digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
res.sendStatus(200); // answer first, work after
const event = JSON.parse(raw);
// switch (event.topic) { ... }
});import base64, hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["SUPR_WEBHOOK_SECRET"].encode() # sss_whsec_...
app = Flask(__name__)
@app.post("/hooks/supr")
def supr():
raw = request.get_data() # raw bytes, before any JSON parsing
timestamp = request.headers.get("X-SSS-Timestamp", "")
signature = request.headers.get("X-SSS-Hmac-SHA256", "")
if abs(time.time() - int(timestamp or 0)) > 300:
abort(400)
digest = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).digest()
if not hmac.compare_digest(base64.b64encode(digest).decode(), signature):
abort(401)
event = request.get_json()
# handle event["topic"]
return "", 200Retries
A delivery that fails (a non 2xx answer, a timeout, or a connection error) is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then marked dead. That is about eight attempts over roughly 21 hours.
If an endpoint fails 20 times in a row we switch it off, so a retired URL is not retried forever. The endpoint shows disabled_at and the last error. Fix the receiver, then turn it back on with PATCH and { "enabled": true }, or Turn on in the app.
Webhook endpoints API
Manage where events go from your own code. The same things are available on the settings page in the app.
List topics
/webhook_topicsNeeds the webhooks:read scope.
Every topic you can subscribe to, always current.
curl "https://subscriptions.super-simple.co/api/v1/webhook_topics" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_topics", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/webhook_topics",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"topics": [
"subscription.created",
"subscription.updated",
"subscription.paused",
...
]
}Get a sample payload
/webhook_topics/:topic/sampleNeeds the webhooks:read scope.
An example of what a topic delivers, built by the same code as a real delivery from an invented subscription.
curl "https://subscriptions.super-simple.co/api/v1/webhook_topics/subscription.paused/sample" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_topics/subscription.paused/sample", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/webhook_topics/subscription.paused/sample",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"topic": "subscription.paused",
"payload": {
"id": "evt_sample",
"topic": "subscription.paused",
...
}
}List endpoints
/webhook_endpointsNeeds the webhooks:read scope.
Your endpoints, newest first.
curl "https://subscriptions.super-simple.co/api/v1/webhook_endpoints" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){ "webhook_endpoints": [ {
"id": "66f1c0a2b7d3e4a5c6d7e8f9",
"url": "https://example.com/hooks/supr",
"topics": ["subscription.paused", "subscription.cancelled"],
"description": "Warehouse system",
"enabled": true,
"created_at": "2026-10-30T10:00:00.000Z",
"last_success_at": null,
"last_error_at": null,
"last_error": null,
"consecutive_failures": 0,
"disabled_at": null
} ] }Create an endpoint
/webhook_endpointsNeeds the webhooks:write scope.
Registers a URL to receive events. The response includes the signing secret, shown once. Store it somewhere safe, or rotate it later.
Body parameters
urlstringrequiredWhere to send events. HTTPS only.
topicsarrayrequiredOne or more topics. An unknown topic is refused and the message lists the valid ones.
descriptionstringA note to yourself.
curl -X POST "https://subscriptions.super-simple.co/api/v1/webhook_endpoints" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/supr","topics":["subscription.paused","subscription.cancelled"],"description":"Warehouse system"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://example.com/hooks/supr",
"topics": [
"subscription.paused",
"subscription.cancelled"
],
"description": "Warehouse system"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
json={
"url": "https://example.com/hooks/supr",
"topics": [
"subscription.paused",
"subscription.cancelled"
],
"description": "Warehouse system"
},
)
data = res.json(){
"webhook_endpoint": {
"id": "66f1c0a2b7d3e4a5c6d7e8f9",
"url": "https://example.com/hooks/supr",
"topics": ["subscription.paused", "subscription.cancelled"],
"description": "Warehouse system",
"enabled": true,
"created_at": "2026-10-30T10:00:00.000Z",
"last_success_at": null,
"last_error_at": null,
"last_error": null,
"consecutive_failures": 0,
"disabled_at": null
},
"secret": "sss_whsec_..."
}Get an endpoint
/webhook_endpoints/:idNeeds the webhooks:read scope.
One endpoint, with its health: when it last succeeded or failed and how many failures in a row.
curl "https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){ "webhook_endpoint": {
"id": "66f1c0a2b7d3e4a5c6d7e8f9",
"url": "https://example.com/hooks/supr",
"topics": ["subscription.paused", "subscription.cancelled"],
"description": "Warehouse system",
"enabled": true,
"created_at": "2026-10-30T10:00:00.000Z",
"last_success_at": null,
"last_error_at": null,
"last_error": null,
"consecutive_failures": 0,
"disabled_at": null
} }Change an endpoint
/webhook_endpoints/:idNeeds the webhooks:write scope.
Send any of the fields below. Turning an endpoint back on clears its failure count.
Body parameters
urlstringA new HTTPS URL.
topicsarrayReplaces the whole list of topics.
descriptionstringA note to yourself.
enabledbooleanfalsepauses deliveries.trueresumes them.
curl -X PATCH "https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"topics":["subscription.cancelled"],"enabled":true}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"topics": [
"subscription.cancelled"
],
"enabled": true
}),
});
const data = await res.json();import os, requests
res = requests.patch(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
json={
"topics": [
"subscription.cancelled"
],
"enabled": True
},
)
data = res.json(){ "webhook_endpoint": {
"id": "66f1c0a2b7d3e4a5c6d7e8f9",
"url": "https://example.com/hooks/supr",
"topics": ["subscription.paused", "subscription.cancelled"],
"description": "Warehouse system",
"enabled": true,
"created_at": "2026-10-30T10:00:00.000Z",
"last_success_at": null,
"last_error_at": null,
"last_error": null,
"consecutive_failures": 0,
"disabled_at": null
} }Delete an endpoint
/webhook_endpoints/:idNeeds the webhooks:write scope.
Stops all deliveries to it.
curl -X DELETE "https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.delete(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){ "deleted": true, "id": "66f1c0a2b7d3e4a5c6d7e8f9" }Rotate the signing secret
/webhook_endpoints/:id/rotate_secretNeeds the webhooks:write scope.
Issues a new secret for this endpoint only. The old one stops working at once, so update your receiver first or accept a short gap. The new secret is shown once.
curl -X POST "https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/rotate_secret" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/rotate_secret", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/rotate_secret",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"webhook_endpoint": {
"id": "66f1c0a2b7d3e4a5c6d7e8f9",
"url": "https://example.com/hooks/supr",
"topics": ["subscription.paused", "subscription.cancelled"],
"description": "Warehouse system",
"enabled": true,
"created_at": "2026-10-30T10:00:00.000Z",
"last_success_at": null,
"last_error_at": null,
"last_error": null,
"consecutive_failures": 0,
"disabled_at": null
},
"secret": "sss_whsec_..."
}Send a test event
/webhook_endpoints/:id/testNeeds the webhooks:write scope.
Queues an example through the real delivery path, with the same signing and retries. The payload carries "test": true and an id starting evt_test_. At most five tests can wait on an endpoint, and a switched off endpoint refuses with 409.
Body parameters
topicstringAny topic. Defaults to the endpoint's first.
curl -X POST "https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/test" \
-H "Authorization: Bearer $SUPR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"topic":"subscription.paused"}'const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"topic": "subscription.paused"
}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/test",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
json={
"topic": "subscription.paused"
},
)
data = res.json(){
"delivery": {
"id": "66f1c0a2b7d3e4a5c6d7e900",
"topic": "subscription.paused",
"status": "pending",
"test": true
}
}List deliveries
/webhook_endpoints/:id/deliveriesNeeds the webhooks:read scope.
The 50 most recent deliveries to this endpoint, with their status. status is pending, delivering, delivered or dead.
curl "https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/deliveries" \
-H "Authorization: Bearer $SUPR_TOKEN"const res = await fetch("https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/deliveries", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SUPR_TOKEN}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://subscriptions.super-simple.co/api/v1/webhook_endpoints/1000001/deliveries",
headers={
"Authorization": f"Bearer {os.environ['SUPR_TOKEN']}",
},
)
data = res.json(){
"deliveries": [
{
"id": "66f1c0a2b7d3e4a5c6d7e900",
"topic": "subscription.paused",
"status": "delivered",
"attempts": 1,
"last_status_code": 200,
"last_error": null,
"next_attempt_at": null,
"delivered_at": "2026-10-30T10:01:04.000Z",
"created_at": "2026-10-30T10:01:02.000Z"
}
]
}Questions
Do I need the API to move many renewal dates at once?
Not for a one off change. In the app's Subscriptions list, filter, tick the subscriptions and use the Reschedule bulk action. The API is for changes your own systems make regularly.
Can another app read subscription data?
Shopify only lets the app that created a subscription read it, so other apps cannot see ours directly. Connect that system to this API, or have it read the order and customer tags the app can add.
Is there an official client library?
Not yet. The API is plain JSON over HTTPS, so any HTTP client works.
Is there a sandbox?
Use a development store. The API works the same there and never touches your live store.
Can I use Zapier instead?
Yes. Connect it with one click in Settings > Integrations > Zapier. No token needed.
Something is not behaving.
Message us in the app's live chat and quote the X-Request-Id from the response. For a general tour, see the Using the API help article.