Gate digital files and pages behind product purchases, subscriptions, or manual grants
/admin/api/access-groups
List every access group ordered by name. Each row includes aggregate counts: files attached, pages attached, granting products, and unique members with active entitlement.
/admin/api/access-groups
Create a new access group. Slug must be 1–64 characters, lowercase alphanumerics and hyphens only, with no leading or trailing hyphen, and unique within the shop.
/admin/api/access-groups/{id}
Fetch full detail for a single access group, including the attached files, pages, granting products, and per-source member rows.
/admin/api/access-groups/{id}
Update an access group's slug, name, and description. All three fields are sent on every call; the new slug must remain unique.
/admin/api/access-groups/{id}
Permanently delete an access group. All file, page, product-assignment, and manual-grant linkages are removed via cascade. Customers lose any entitlement that was derived solely from this group.
/admin/api/access-groups/{id}/files
Replace the full set of digital files attached to a group. The `file_ids` array becomes the exact membership after the call — files not listed are removed, and an empty array clears the group. The call is idempotent.
/admin/api/access-groups/{id}/pages
Replace the full set of custom pages attached to a group. The `page_ids` array becomes the exact membership after the call — pages not listed are detached, and an empty array clears the group. The call is idempotent.
/admin/api/access-groups/{id}/members
List all current members of a group with one row per (customer × entitlement source). A customer entitled via both an order and a manual grant appears twice, once per source. `source` is one of "order", "subscription", or "manual_grant". Expired manual grants are still returned so admins can see and revoke them.
/admin/api/access-groups/{id}/grants
Manually grant a customer access to a group. The created grant is independent of any product purchase or subscription — use it for comps, gifts, or migrating customers from another platform. The admin who created the grant is recorded as `granted_by`.
/admin/api/access-grants/{grant_id}
Revoke a manual access grant by its grant ID. Only affects manual grants — order- and subscription-based access cannot be revoked through this endpoint. Note the path uses `access-grants`, not `access-groups`.
/admin/api/products/{id}/access-groups
List the access groups a product grants on purchase, enriched with each group's slug and name plus the configured access duration.
/admin/api/products/{id}/access-groups
Replace the full set of access-group assignments for a product. The `assignments` array becomes the exact set after the call — groups not listed are detached. Each assignment can set `duration_days` for time-limited access, or null for lifetime. Send `{"assignments": []}` to remove all groups from the product.
/admin/api/access-groups
List every access group ordered by name. Each row includes aggregate counts: files attached, pages attached, granting products, and unique members with active entitlement.
[
{
"id": 1,
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers.",
"file_count": 4,
"page_count": 2,
"granting_product_count": 3,
"member_count": 128,
"created_at": "2026-03-10T09:00:00Z",
"updated_at": "2026-04-22T14:30:00Z"
},
{
"id": 2,
"slug": "course-advanced",
"name": "Advanced Course",
"description": null,
"file_count": 12,
"page_count": 6,
"granting_product_count": 1,
"member_count": 47,
"created_at": "2026-03-15T10:00:00Z",
"updated_at": "2026-03-15T10:00:00Z"
}
]
curl "https://yourshop.zeroshop.io/admin/api/access-groups" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/access-groups",
headers={"Authorization": "Bearer zspat_..."}
)
groups = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/access-groups", {
headers: { "Authorization": "Bearer zspat_..." }
});
const groups = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
groups = JSON.parse(resp.body)
/admin/api/access-groups
Create a new access group. Slug must be 1–64 characters, lowercase alphanumerics and hyphens only, with no leading or trailing hyphen, and unique within the shop.
{
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers."
}
{
"id": 1,
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers.",
"file_count": 0,
"page_count": 0,
"granting_product_count": 0,
"member_count": 0,
"created_at": "2026-05-13T10:00:00Z",
"updated_at": "2026-05-13T10:00:00Z"
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/access-groups" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"slug":"premium","name":"Premium Content","description":"Bonus material for premium-tier buyers."}'
import requests
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/access-groups",
headers={"Authorization": "Bearer zspat_..."},
json={
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers."
}
)
group = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/access-groups", {
method: "POST",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
slug: "premium",
name: "Premium Content",
description: "Bonus material for premium-tier buyers."
})
});
const group = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
slug: "premium",
name: "Premium Content",
description: "Bonus material for premium-tier buyers."
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
group = JSON.parse(resp.body)
/admin/api/access-groups/{id}
Fetch full detail for a single access group, including the attached files, pages, granting products, and per-source member rows.
{
"id": 1,
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers.",
"file_count": 2,
"page_count": 1,
"granting_product_count": 1,
"member_count": 2,
"created_at": "2026-03-10T09:00:00Z",
"updated_at": "2026-04-22T14:30:00Z",
"files": [
{
"id": 11,
"original_filename": "premium-handbook.pdf",
"file_size": 1048576,
"content_type": "application/pdf",
"storage_key": "tenants/42/files/abc123.pdf",
"sort_order": 0,
"created_at": "2026-03-11T08:00:00Z"
},
{
"id": 12,
"original_filename": "bonus-tracks.zip",
"file_size": 8388608,
"content_type": "application/zip",
"storage_key": "tenants/42/files/def456.zip",
"sort_order": 1,
"created_at": "2026-03-12T08:00:00Z"
}
],
"pages": [
{
"id": 5,
"slug": "premium-welcome",
"title": "Welcome, Premium Member"
}
],
"granting_products": [
{
"product_id": 17,
"product_slug": "premium-bundle",
"product_name": "Premium Bundle",
"duration_days": null,
"is_subscription": false
}
],
"members": [
{
"customer_id": 301,
"customer_email": "alice@example.com",
"customer_name": "Alice Schmidt",
"source": "order",
"source_id": 9001,
"expires_at": null,
"note": null,
"group_name": "Premium Content",
"group_slug": "premium"
},
{
"customer_id": 302,
"customer_email": "bob@example.com",
"customer_name": null,
"source": "manual_grant",
"source_id": 14,
"expires_at": "2026-12-31T23:59:59Z",
"note": "Comp for support issue #482",
"group_name": "Premium Content",
"group_slug": "premium"
}
]
}
curl "https://yourshop.zeroshop.io/admin/api/access-groups/1" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/access-groups/1",
headers={"Authorization": "Bearer zspat_..."}
)
group = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/access-groups/1", {
headers: { "Authorization": "Bearer zspat_..." }
});
const group = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
group = JSON.parse(resp.body)
/admin/api/access-groups/{id}
Update an access group's slug, name, and description. All three fields are sent on every call; the new slug must remain unique.
{
"slug": "premium-plus",
"name": "Premium Plus",
"description": "Premium content with extra bonus tracks."
}
{
"id": 1,
"slug": "premium-plus",
"name": "Premium Plus",
"description": "Premium content with extra bonus tracks.",
"file_count": 2,
"page_count": 1,
"granting_product_count": 1,
"member_count": 128,
"created_at": "2026-03-10T09:00:00Z",
"updated_at": "2026-05-13T10:05:00Z"
}
curl -X PATCH "https://yourshop.zeroshop.io/admin/api/access-groups/1" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"slug":"premium-plus","name":"Premium Plus","description":"Premium content with extra bonus tracks."}'
import requests
resp = requests.patch(
"https://yourshop.zeroshop.io/admin/api/access-groups/1",
headers={"Authorization": "Bearer zspat_..."},
json={
"slug": "premium-plus",
"name": "Premium Plus",
"description": "Premium content with extra bonus tracks."
}
)
group = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/access-groups/1", {
method: "PATCH",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
slug: "premium-plus",
name: "Premium Plus",
description: "Premium content with extra bonus tracks."
})
});
const group = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1")
req = Net::HTTP::Patch.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
slug: "premium-plus",
name: "Premium Plus",
description: "Premium content with extra bonus tracks."
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
group = JSON.parse(resp.body)
/admin/api/access-groups/{id}
Permanently delete an access group. All file, page, product-assignment, and manual-grant linkages are removed via cascade. Customers lose any entitlement that was derived solely from this group.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/access-groups/1" \
-H "Authorization: Bearer zspat_..."
import requests
requests.delete(
"https://yourshop.zeroshop.io/admin/api/access-groups/1",
headers={"Authorization": "Bearer zspat_..."}
)
await fetch("https://yourshop.zeroshop.io/admin/api/access-groups/1", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/access-groups/{id}/files
Replace the full set of digital files attached to a group. The `file_ids` array becomes the exact membership after the call — files not listed are removed, and an empty array clears the group. The call is idempotent.
{
"file_ids": [
11,
12,
15
]
}
{
"id": 1,
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers.",
"file_count": 3,
"page_count": 1,
"granting_product_count": 1,
"member_count": 128,
"created_at": "2026-03-10T09:00:00Z",
"updated_at": "2026-05-13T10:10:00Z"
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/access-groups/1/files" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"file_ids":[11,12,15]}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/access-groups/1/files",
headers={"Authorization": "Bearer zspat_..."},
json={"file_ids": [11, 12, 15]}
)
group = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/access-groups/1/files", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ file_ids: [11, 12, 15] })
});
const group = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1/files")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { file_ids: [11, 12, 15] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
group = JSON.parse(resp.body)
/admin/api/access-groups/{id}/pages
Replace the full set of custom pages attached to a group. The `page_ids` array becomes the exact membership after the call — pages not listed are detached, and an empty array clears the group. The call is idempotent.
{
"page_ids": [
5,
7
]
}
{
"id": 1,
"slug": "premium",
"name": "Premium Content",
"description": "Bonus material for premium-tier buyers.",
"file_count": 3,
"page_count": 2,
"granting_product_count": 1,
"member_count": 128,
"created_at": "2026-03-10T09:00:00Z",
"updated_at": "2026-05-13T10:11:00Z"
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/access-groups/1/pages" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"page_ids":[5,7]}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/access-groups/1/pages",
headers={"Authorization": "Bearer zspat_..."},
json={"page_ids": [5, 7]}
)
group = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/access-groups/1/pages", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ page_ids: [5, 7] })
});
const group = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1/pages")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { page_ids: [5, 7] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
group = JSON.parse(resp.body)
/admin/api/access-groups/{id}/members
List all current members of a group with one row per (customer × entitlement source). A customer entitled via both an order and a manual grant appears twice, once per source. `source` is one of "order", "subscription", or "manual_grant". Expired manual grants are still returned so admins can see and revoke them.
[
{
"customer_id": 301,
"customer_email": "alice@example.com",
"customer_name": "Alice Schmidt",
"source": "order",
"source_id": 9001,
"expires_at": null,
"note": null,
"group_name": "Premium Content",
"group_slug": "premium"
},
{
"customer_id": 301,
"customer_email": "alice@example.com",
"customer_name": "Alice Schmidt",
"source": "manual_grant",
"source_id": 14,
"expires_at": "2026-12-31T23:59:59Z",
"note": "Promo from launch event",
"group_name": "Premium Content",
"group_slug": "premium"
},
{
"customer_id": 302,
"customer_email": "bob@example.com",
"customer_name": null,
"source": "subscription",
"source_id": 55,
"expires_at": null,
"note": null,
"group_name": "Premium Content",
"group_slug": "premium"
}
]
curl "https://yourshop.zeroshop.io/admin/api/access-groups/1/members" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/access-groups/1/members",
headers={"Authorization": "Bearer zspat_..."}
)
members = resp.json()
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/access-groups/1/members",
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const members = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1/members")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
members = JSON.parse(resp.body)
/admin/api/access-groups/{id}/grants
Manually grant a customer access to a group. The created grant is independent of any product purchase or subscription — use it for comps, gifts, or migrating customers from another platform. The admin who created the grant is recorded as `granted_by`.
{
"customer_id": 302,
"expires_at": "2026-12-31T23:59:59Z",
"note": "Comp for support issue #482"
}
{
"id": 14,
"customer_id": 302,
"group_id": 1,
"expires_at": "2026-12-31T23:59:59Z",
"granted_by": 4,
"note": "Comp for support issue #482",
"created_at": "2026-05-13T10:15:00Z"
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/access-groups/1/grants" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"customer_id":302,"expires_at":"2026-12-31T23:59:59Z","note":"Comp for support issue #482"}'
import requests
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/access-groups/1/grants",
headers={"Authorization": "Bearer zspat_..."},
json={
"customer_id": 302,
"expires_at": "2026-12-31T23:59:59Z",
"note": "Comp for support issue #482"
}
)
grant = resp.json()
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/access-groups/1/grants",
{
method: "POST",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
customer_id: 302,
expires_at: "2026-12-31T23:59:59Z",
note: "Comp for support issue #482"
})
}
);
const grant = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-groups/1/grants")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
customer_id: 302,
expires_at: "2026-12-31T23:59:59Z",
note: "Comp for support issue #482"
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
grant = JSON.parse(resp.body)
/admin/api/access-grants/{grant_id}
Revoke a manual access grant by its grant ID. Only affects manual grants — order- and subscription-based access cannot be revoked through this endpoint. Note the path uses `access-grants`, not `access-groups`.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/access-grants/14" \
-H "Authorization: Bearer zspat_..."
import requests
requests.delete(
"https://yourshop.zeroshop.io/admin/api/access-grants/14",
headers={"Authorization": "Bearer zspat_..."}
)
await fetch("https://yourshop.zeroshop.io/admin/api/access-grants/14", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/access-grants/14")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/access-groups
List the access groups a product grants on purchase, enriched with each group's slug and name plus the configured access duration.
[
{
"group_id": 1,
"slug": "premium",
"name": "Premium Content",
"duration_days": null
},
{
"group_id": 2,
"slug": "course-advanced",
"name": "Advanced Course",
"duration_days": 365
}
]
curl "https://yourshop.zeroshop.io/admin/api/products/17/access-groups" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/17/access-groups",
headers={"Authorization": "Bearer zspat_..."}
)
assignments = resp.json()
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/17/access-groups",
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const assignments = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/17/access-groups")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
assignments = JSON.parse(resp.body)
/admin/api/products/{id}/access-groups
Replace the full set of access-group assignments for a product. The `assignments` array becomes the exact set after the call — groups not listed are detached. Each assignment can set `duration_days` for time-limited access, or null for lifetime. Send `{"assignments": []}` to remove all groups from the product.
{
"assignments": [
{
"group_id": 1,
"duration_days": null
},
{
"group_id": 2,
"duration_days": 365
}
]
}
[
{
"group_id": 1,
"slug": "premium",
"name": "Premium Content",
"duration_days": null
},
{
"group_id": 2,
"slug": "course-advanced",
"name": "Advanced Course",
"duration_days": 365
}
]
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/17/access-groups" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"assignments":[{"group_id":1,"duration_days":null},{"group_id":2,"duration_days":365}]}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/17/access-groups",
headers={"Authorization": "Bearer zspat_..."},
json={
"assignments": [
{"group_id": 1, "duration_days": None},
{"group_id": 2, "duration_days": 365},
]
}
)
assignments = resp.json()
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/17/access-groups",
{
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
assignments: [
{ group_id: 1, duration_days: null },
{ group_id: 2, duration_days: 365 }
]
})
}
);
const assignments = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/17/access-groups")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
assignments: [
{ group_id: 1, duration_days: nil },
{ group_id: 2, duration_days: 365 }
]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
assignments = JSON.parse(resp.body)
We value your privacy
We use cookies for essential site functionality and, with your consent, analytics to understand how our platform is used. No personal data is shared with third parties. See our Privacy Policy for details.