Platform API
PRE-RELEASE WARNING: This API is in pre-release and changes often. Breaking changes may come before a stable v1.
The platform API lets the tools that run beside a hosted Easel platform, such as its staff’s console, read and run the platform as a whole: its stores and their owners, its users, tiers, staff, log, and the content moderation queue. It isn’t the store API. A store’s own tools use the store API with the store’s API keys.
The API is served only on the platform listener,
EASEL_PLATFORM_HOST_PORT, which exists only in platform mode
(EASEL_MODE=platform) and listens on loopback, 127.0.0.1:3011, by
default, off the internet. A tool on the same machine calls
http://127.0.0.1:3011/api/platform/v1/. Its token travels unencrypted,
so keep the listener on loopback. The listener serves nothing but this
API.
Base URL
http://127.0.0.1:3011/api/platform/v1/
Endpoints
Reading:
- Platform Stats -
GET /api/platform/v1/stats/ - List Platform Events -
GET /api/platform/v1/events/ - List Stores -
GET /api/platform/v1/stores/ - Get Store -
GET /api/platform/v1/stores/{store_id}/ - List Store Owners -
GET /api/platform/v1/stores/{store_id}/owners/ - Get Staff Access -
GET /api/platform/v1/stores/{store_id}/staff-access/ - List Users -
GET /api/platform/v1/users/ - Get User -
GET /api/platform/v1/users/{user_id}/ - List Tiers -
GET /api/platform/v1/tiers/ - Get Tier -
GET /api/platform/v1/tiers/{tier_id}/ - List Staff -
GET /api/platform/v1/staff/ - Get Staff -
GET /api/platform/v1/staff/{staff_id}/ - List Moderation Items -
GET /api/platform/v1/moderation/items/ - Get Moderation Item -
GET /api/platform/v1/moderation/items/{item_id}/ - Get Moderation Item Image -
GET /api/platform/v1/moderation/items/{item_id}/image/ - List Notices -
GET /api/platform/v1/moderation/notices/ - Get Notice -
GET /api/platform/v1/moderation/notices/{notice_id}/ - List Decisions -
GET /api/platform/v1/moderation/decisions/ - Get Decision -
GET /api/platform/v1/moderation/decisions/{decision_id}/
Changing, with who must name themselves in Easel-Staff:
anyone (no one need be named), a member of the staff, or the operator.
| Endpoint | Who |
|---|---|
Lock Store - POST /stores/{store_id}/lock/ |
Anyone |
Unlock Store - POST /stores/{store_id}/unlock/ |
Anyone |
Delete Store - DELETE /stores/{store_id}/ |
Operator |
Change Store Tier - PUT /stores/{store_id}/tier/ |
Member, who must be the operator |
Set Store Plan - PUT /stores/{store_id}/plan/ |
Anyone; the operator, if a member is named and the tier changes |
Suspend Store - POST /stores/{store_id}/suspend/ |
Member |
Unsuspend Store - POST /stores/{store_id}/unsuspend/ |
Member |
Review Store - POST /stores/{store_id}/reviewed/ |
Member |
Redeem Staff Access - POST /stores/{store_id}/staff-access/redeem/ |
Member |
Open Staff Access - POST /stores/{store_id}/staff-access/open/ |
Member |
Ban User - POST /users/{user_id}/ban/ |
Member |
Unban User - POST /users/{user_id}/unban/ |
Member |
Reset User Passkeys - POST /users/{user_id}/reset-passkeys/ |
Member |
Delete User - DELETE /users/{user_id}/ |
Operator |
Review Moderation Item - POST /moderation/items/{item_id}/reviewed/ |
Member |
Take Down - POST /moderation/items/{item_id}/take-down/ |
Member |
Restore - POST /moderation/items/{item_id}/restore/ |
Member |
Dismiss Notice - POST /moderation/notices/{notice_id}/dismiss/ |
Member |
Add Staff - POST /staff/ |
Operator |
Disable and Enable Staff - POST /staff/{staff_id}/disable/, /enable/ |
Operator |
Create Tier - POST /tiers/ |
Operator |
Change Tier - PUT /tiers/{tier_id}/ |
Operator |
Redeem Billing Handoff - POST /billing-handoffs/redeem/ |
Anyone |
Token
Each request presents the platform’s token in the Authorization header:
Authorization: Bearer 3f9c...
The token is the server’s EASEL_PLATFORM_TOKEN, a secret: keep it in a
credential file, as with systemd’s LoadCredential=, like the server’s
other secrets, and give the same token to the tools that call the API.
make init generates one in a checkout whose env_easel.ini sets
EASEL_MODE=platform. Platform mode won’t start without it, and store
mode, which has no platform API, won’t start with it. The server compares
the token in constant time, and never logs it.
The token lets a request do anything the API does. A request without it
is 401 Unauthorized, with MISSING_AUTHORIZATION, and one with another
token 401 Unauthorized, with INVALID_AUTHORIZATION.
To replace the token, set the new one on the server and the tools together and restart them.
Acting as Staff
A request may name the member of Easel’s staff it acts for, by their ID,
in the Easel-Staff header:
Easel-Staff: stf_d0b6fmv28q6vn14peun0
The logs then name that member for whatever the request changes. Without
the header, a request acts as Easel, naming no one. A member no one has,
or a member who was disabled, is 403 Forbidden.
A change that needs a member of the staff, as the endpoints
say, is 403 Forbidden without one named, and one only the operator may
make is 403 Forbidden naming anyone else:
{
"error": "Only the operator may do this",
"code": "INSUFFICIENT_PERMISSIONS"
}
Rate Limits
The token may make 600 requests a minute, in bursts of up to 120. Each
address may fail to sign in 30 times a minute, apart from that. A request
over either is 429 Too Many Requests, with Retry-After:
{
"error": "Rate limit exceeded",
"code": "RATE_LIMITED"
}
Errors
Errors use the store API’s format:
| Status | Code | When |
|---|---|---|
400 Bad Request |
INVALID_REQUEST_ERROR |
A bad query parameter, which the message names |
400 Bad Request |
INVALID_JSON |
A body that isn’t JSON, or holds a field the request has none of, such as one misspelled |
400 Bad Request |
MISSING_FIELD |
A body without a field the request needs |
400 Bad Request |
INVALID_FIELD |
A field with a value there isn’t, such as a tier_id naming no tier, a wrong staff access code, or an explanation empty or over 4,000 characters |
401 Unauthorized |
MISSING_AUTHORIZATION, INVALID_AUTHORIZATION |
No token, or another (see Token) |
403 Forbidden |
INSUFFICIENT_PERMISSIONS |
A member of the staff not named, unknown, disabled, or not the operator (see Acting as Staff) |
404 Not Found |
NOT_FOUND |
A record that isn’t there, or a list cursor naming one |
409 Conflict |
CONFLICT |
A change the record’s state doesn’t allow now, such as unlocking a store that isn’t locked, or taking the default from the default tier |
409 Conflict |
DUPLICATE |
A member of the staff with a username taken |
410 Gone |
GONE |
A billing handoff’s token used already, expired, or no longer good for its user |
413 Request Entity Too Large |
REQUEST_TOO_LARGE |
A body over 64 KiB |
429 Too Many Requests |
RATE_LIMITED |
Over a rate limit, or too many wrong staff access codes; each with Retry-After |
A change answers with what it changed, as its section shows, or
204 No Content when there’s nothing to show. Request bodies are JSON.
Pagination
Lists page with after, before, and limit as the store API’s
do: limit is 10 by default and at most 100, and
after and before can’t be used together. A list keeps its order
whichever way you page, and has_more says whether more follow in the
direction of travel. Every list pages.
Every field is always present. A field with nothing to say is null.
Platform Stats
Read the platform as a whole.
Endpoint: GET /api/platform/v1/stats/
Sales hold one total per currency, ordered by currency code.
paying_stores counts the stores whose plan is active, and
past_due_stores those past_due. failed_emails counts the email
that gave up after every retry. updated_at is when the counts kept as
stores change were last brought up to date.
Response
{
"total_stores": 120,
"trial_stores": 14,
"paying_stores": 96,
"past_due_stores": 2,
"total_users": 151,
"total_customers": 48210,
"total_orders": 70344,
"failed_emails": 0,
"total_sales": [
{"amount": 912345600, "currency": "EUR"},
{"amount": 15402300, "currency": "USD"}
],
"updated_at": "2026-11-01T09:00:00Z"
}
List Platform Events
Read the platform’s log, newest first: what Easel’s staff did that no store’s activity log can keep, such as deleting a store, erasing a user, or adding a member of the staff.
Endpoint: GET /api/platform/v1/events/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
after |
string | Event ID to return older events after |
before |
string | Event ID to return the newer events before, for paging back |
limit |
integer | Number of events to return (default 10, max 100) |
action is store_deleted, user_deleted, user_passkeys_reset,
user_shadow_banned, user_unbanned, staff_added, staff_disabled,
staff_enabled, tier_created, or tier_updated; new actions may be
added, so ignore those you don’t recognize. subject is what it was done
to: a tier by its name after the change, a store or member of the staff
by their name then; its name is null for a user, so the log keeps none
of their personal data. staff is who did it, null when no member was
named.
Response
{
"data": [
{
"id": "pev_d0b6fmv28q6vn14peun0",
"action": "store_deleted",
"subject": {"id": "sto_d0b6db728q6vrspbvbqg", "name": "Alder Pottery"},
"staff": {"id": "stf_d0b6cq728q6vrspbvb10", "name": "Sam"},
"created_at": "2026-10-30T14:02:11Z"
}
],
"has_more": false
}
List Stores
List the platform’s stores in the order they opened.
Endpoint: GET /api/platform/v1/stores/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
q |
string | Only stores whose name contains this |
reviewed |
string | false for only the stores Easel’s staff haven’t yet reviewed as legitimate businesses. Can’t be used with q |
billing_status |
string | Only the stores whose plan stands so: trialing, active, past_due, canceled, or free. Can’t be used with q or reviewed |
after |
string | Store ID to return the stores after |
before |
string | Store ID to return the stores before, for paging back |
limit |
integer | Number of stores to return (default 10, max 100) |
The list holds deleted stores too, until they’re purged, with their
deleted_at. tier_id is the tier a store is on.
locked_at is set while a store is locked for its billing, and
suspended_at while Easel has suspended it for its content, for
suspension_category. reviewed_at is when Easel’s staff judged it a
legitimate business. billing_status is where its Easel plan stands, as
Set Store Plan last set it; paid_until is when the
period it paid for ends, null unless it’s active, past_due, or
canceled; and trial_ends_at is when its free trial ends, kept after
the trial.
Response
{
"data": [
{
"id": "sto_d0b6db728q6vrspbvbqg",
"name": "Alder Pottery",
"tier_id": "str_d0b5r2728q6vrspbv9n0",
"currency": "EUR",
"locked_at": null,
"suspended_at": null,
"suspension_category": null,
"reviewed_at": "2026-10-02T08:15:00Z",
"billing_status": "active",
"paid_until": "2026-11-30T17:40:12Z",
"trial_ends_at": "2026-10-30T17:40:12Z",
"deleted_at": null,
"created_at": "2026-09-30T17:40:12Z"
}
],
"has_more": true
}
Get Store
Read a store, with the tier it’s on, how many products it has, and what it has sold in all, in its currency.
Endpoint: GET /api/platform/v1/stores/{store_id}/
Response
{
"id": "sto_d0b6db728q6vrspbvbqg",
"name": "Alder Pottery",
"tier_id": "str_d0b5r2728q6vrspbv9n0",
"currency": "EUR",
"locked_at": null,
"suspended_at": null,
"suspension_category": null,
"reviewed_at": "2026-10-02T08:15:00Z",
"billing_status": "active",
"paid_until": "2026-11-30T17:40:12Z",
"trial_ends_at": "2026-10-30T17:40:12Z",
"deleted_at": null,
"created_at": "2026-09-30T17:40:12Z",
"tier": {"id": "str_d0b5r2728q6vrspbv9n0", "name": "Studio"},
"product_count": 42,
"revenue": {"amount": 1834500, "currency": "EUR"}
}
List Store Owners
List a store’s owners, whom Easel tells about its plan and standing, in the order they joined Easel.
Endpoint: GET /api/platform/v1/stores/{store_id}/owners/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
after |
string | User ID to return the owners after |
before |
string | User ID to return the owners before, for paging back |
limit |
integer | Number of owners to return (default 10, max 100) |
language is the one Easel writes to the owner in, as a BCP 47 tag.
Response
{
"data": [
{
"id": "usr_d0b6db728q6vrspbvbr0",
"name": "Ana Silva",
"email": "ana@alderpottery.example",
"language": "pt"
}
],
"has_more": false
}
Get Staff Access
Read whether a store’s owner has let Easel’s staff into its dashboard: a
code waiting to be redeemed, or an access a member is using. access is
null otherwise.
Endpoint: GET /api/platform/v1/stores/{store_id}/staff-access/
state is pending while the code waits, until code_expires_at, and
active while redeemed_by uses it, until ends_at. created_by names
the store’s user who made it, while they’re in the store. The code itself
is never shown: the owner reads it out.
Response
{
"store": {"id": "sto_d0b6db728q6vrspbvbqg", "name": "Alder Pottery"},
"access": {
"id": "sta_d0b7a1728q6vrspbvc00",
"state": "active",
"duration": "24h",
"code_expires_at": "2026-11-01T10:30:00Z",
"failed_attempts": 0,
"redeemed_by": {"id": "stf_d0b6cq728q6vrspbvb10", "name": "Sam"},
"redeemed_at": "2026-11-01T10:02:00Z",
"ends_at": "2026-11-02T10:02:00Z",
"ended_at": null,
"end_reason": null,
"created_by": "Ana Silva",
"created_at": "2026-11-01T10:00:00Z"
}
}
List Users
List the dashboard users in the order they joined.
Endpoint: GET /api/platform/v1/users/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
q |
string | Only users whose email contains this |
after |
string | User ID to return the users after |
before |
string | User ID to return the users before, for paging back |
limit |
integer | Number of users to return (default 10, max 100) |
shadow_banned_at is set while Easel has banned the user.
Response
{
"data": [
{
"id": "usr_d0b6db728q6vrspbvbr0",
"name": "Ana Silva",
"email": "ana@alderpottery.example",
"language": "pt",
"shadow_banned_at": null,
"created_at": "2026-09-30T17:40:12Z"
}
],
"has_more": false
}
Get User
Read a dashboard user, as the list shows them.
Endpoint: GET /api/platform/v1/users/{user_id}/
List Tiers
List the plans stores may be on, in the order they were made.
Endpoint: GET /api/platform/v1/tiers/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
after |
string | Tier ID to return the tiers after |
before |
string | Tier ID to return the tiers before, for paging back |
limit |
integer | Number of tiers to return (default 10, max 100) |
A tier decides what its stores may do; what it costs is the billing
tool’s to decide. New stores start on the one tier that is_default.
Response
{
"data": [
{
"id": "str_d0b5r2728q6vrspbv9n0",
"name": "Studio",
"is_default": true,
"created_at": "2026-02-18T00:00:00Z"
}
],
"has_more": false
}
Get Tier
Read a tier, as the list shows it.
Endpoint: GET /api/platform/v1/tiers/{tier_id}/
List Staff
List Easel’s staff in the order they were added.
Endpoint: GET /api/platform/v1/staff/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
after |
string | Member ID to return the members after |
before |
string | Member ID to return the members before, for paging back |
limit |
integer | Number of members to return (default 10, max 100) |
operator is true for the one member who alone may change the staff,
whom platform mode adds, as admin, when it first starts. disabled_at is set once a member has left; they’re kept so the logs
still name them.
Response
{
"data": [
{
"id": "stf_d0b6cq728q6vrspbvb10",
"username": "sam",
"name": "Sam",
"operator": false,
"disabled_at": null,
"created_at": "2026-03-01T12:00:00Z"
}
],
"has_more": false
}
Get Staff
Read a member of the staff, as the list shows them.
Endpoint: GET /api/platform/v1/staff/{staff_id}/
List Moderation Items
List the stores’ content waiting for review, newest first.
Endpoint: GET /api/platform/v1/moderation/items/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
filter |
string | risky (the default) for the content of stores on a free trial or not yet taking payments, or all for every store’s |
store |
string | Only this store’s content, whatever filter says; a malformed ID is 400 Bad Request, INVALID_FIELD, and an unknown store 404 Not Found |
after |
string | Item ID to return the older items after |
before |
string | Item ID to return the newer items before, for paging back |
limit |
integer | Number of items to return (default 10, max 100) |
Reviewing an item takes it off the queue, and editing or reporting its content puts it back, but for editing the content of a store Easel’s staff have reviewed as a legitimate business, which doesn’t queue. An item used as a cursor after it left the queue stands where it was when reviewed, so a page may repeat an item, but never skips one.
kind is product, collection, page, site, review, image, or
store, and entity_id the ID of the record it’s for. title and body
are the content as it is now. missing says it has since been deleted, and
quarantined that its image was removed as illegal content, which no one
may see. taken_down_at is set while Easel has taken it down.
open_notices counts the notices about it waiting for an answer.
Response
{
"data": [
{
"id": "mod_d0b7c3728q6vrspbvc90",
"store": {"id": "sto_d0b6db728q6vrspbvbqg", "name": "Alder Pottery"},
"kind": "product",
"entity_id": "pro_d0b6fmv28q6vn14peun0",
"title": "Speckled Mug",
"body": "Wheel-thrown stoneware.",
"missing": false,
"quarantined": false,
"queued_at": "2026-11-01T09:12:00Z",
"reviewed_at": null,
"taken_down_at": null,
"open_notices": 0,
"created_at": "2026-10-12T16:20:00Z"
}
],
"has_more": true
}
Get Moderation Item
Read an item, as the list shows it, with three fields more.
Endpoint: GET /api/platform/v1/moderation/items/{item_id}/
| Field | Type | Description |
|---|---|---|
decisions |
array | Up to 20 of the latest decisions about its content, newest first, each as Get Decision shows it; empty, never null, when there are none |
notices |
array | Up to 20 of the notices about it waiting for an answer, newest first, each as Get Notice shows it; empty, never null, when there are none |
quarantine_pending |
boolean | Whether taking its image down as illegal content left something to finish, which taking it down that way again does |
Get Moderation Item Image
Read the smallest size of an image item’s image, with its image
Content-Type, never cached. An image quarantined as illegal content is
404 Not Found to everyone, as is an item that isn’t an image.
Endpoint: GET /api/platform/v1/moderation/items/{item_id}/image/
List Notices
List the notices anyone sent from a storefront about content they think illegal or against Easel’s terms, newest first: those waiting for an answer, or those answered.
Endpoint: GET /api/platform/v1/moderation/notices/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
closed |
string | true for the notices answered, false (the default) for those waiting |
after |
string | Notice ID to return the older notices after |
before |
string | Notice ID to return the newer notices before, for paging back |
limit |
integer | Number of notices to return (default 10, max 100) |
store is the store the notice is about and item_id the queue item it
resolved to, each null when it resolved to none. reporter_email is
null when the reporter left none, and once the notice is answered.
Response
{
"data": [
{
"id": "ntc_d0b7e1728q6vrspbvcg0",
"store": {"id": "sto_d0b6db728q6vrspbvbqg", "name": "Alder Pottery"},
"item_id": "mod_d0b7c3728q6vrspbvc90",
"url": "https://alderpottery.example/products/speckled-mug/",
"category": "ip",
"explanation": "These photos are mine.",
"reporter_name": "Rita",
"reporter_email": "rita@example.com",
"language": "en",
"closed_at": null,
"created_at": "2026-11-01T08:50:00Z"
}
],
"has_more": false
}
Get Notice
Read a notice, as the list shows it.
Endpoint: GET /api/platform/v1/moderation/notices/{notice_id}/
List Decisions
List the decisions Easel made about a store and its content, newest first.
Endpoint: GET /api/platform/v1/moderation/decisions/
Query Parameters
| Parameter | Type | Description |
|---|---|---|
store |
string | Required. The store whose decisions to list |
after |
string | Decision ID to return the older decisions after |
before |
string | Decision ID to return the newer decisions before, for paging back |
limit |
integer | Number of decisions to return (default 10, max 100) |
action is taken_down, restored, suspended, unsuspended, or
no_action, for a notice answered without acting on it. kind and
entity_id name the content, both null for a decision about the store
itself, and entity_name is its name then. source is notice or
own_initiative. image_sha256 is the hash of an image taken down as
illegal content, for the report to the authorities, and null otherwise.
statement_sent_at is when the store was emailed the decision.
Response
{
"data": [
{
"id": "mdc_d0b7f0728q6vrspbvcm0",
"store": {"id": "sto_d0b6db728q6vrspbvbqg", "name": "Alder Pottery"},
"action": "taken_down",
"kind": "product",
"entity_id": "pro_d0b6fmv28q6vn14peun0",
"entity_name": "Speckled Mug",
"category": "ip",
"explanation": "The photos belong to someone else.",
"source": "notice",
"staff_name": "Sam",
"image_sha256": null,
"statement_sent_at": "2026-11-01T09:30:00Z",
"created_at": "2026-11-01T09:29:00Z"
}
],
"has_more": false
}
Get Decision
Read a decision, as the list shows it.
Endpoint: GET /api/platform/v1/moderation/decisions/{decision_id}/
Lock Store
Lock a store for its plan: its storefront and API go offline until it’s
unlocked, and its owners still sign in to its dashboard. The store’s
activity log records it as locked, with the
reason, by the member of the staff the request names, or by Easel.
Endpoint: POST /api/platform/v1/stores/{store_id}/lock/
| Field | Type | Description |
|---|---|---|
reason |
string | Required. billing, for a plan gone unpaid, or trial_ended |
A store locked already is 409 Conflict, unless it’s locked by a
suspension for its content, which it then stays locked after.
curl -X POST "http://127.0.0.1:3011/api/platform/v1/stores/sto_d0b6db728q6vrspbvbqg/lock/" \
-H "Authorization: Bearer $EASEL_PLATFORM_TOKEN" \
-d '{"reason":"billing"}'
Answers 204 No Content.
Unlock Store
Unlock a store locked for its plan. The activity log records it as unlocked. A store not
locked, or suspended for its content, which only
unsuspending lifts, is 409 Conflict.
Endpoint: POST /api/platform/v1/stores/{store_id}/unlock/
Takes no body, and answers 204 No Content.
Delete Store
Delete a store, which is purged with its data later. The platform’s
log records it as store_deleted, by the
operator.
Endpoint: DELETE /api/platform/v1/stores/{store_id}/
Answers 204 No Content.
Change Store Tier
Move a store onto another tier, leaving where its plan stands as it was.
The activity log records the change, as subscription_tier, the tiers by
name, and subscription_tier_id, by ID. Only the operator may, though any
member may ask.
Endpoint: PUT /api/platform/v1/stores/{store_id}/tier/
| Field | Type | Description |
|---|---|---|
tier_id |
string | Required. The tier to move it onto |
A tier_id naming no tier is 400 Bad Request with INVALID_FIELD, as a
field with a value there isn’t; a store there isn’t is 404 Not Found.
Answers 204 No Content.
Set Store Plan
Set where a store’s Easel plan stands, as the tool billing stores reports
it: the tier it’s on, its status, and when the period it paid for ends.
Core’s periodic job locks a store from its plan alone, as this table says,
logging each lock as locked by Easel, with the reason trial_ended or
billing:
billing_status |
Locks once |
|---|---|
trialing |
trial_ends_at has passed |
active, past_due |
paid_until and a week’s grace have passed |
canceled |
paid_until has passed |
free |
Never |
Its dashboard warns of it: when its trial ends, when its canceled
subscription ends, or, past_due or active with paid_until passed,
that its payment is overdue and when it locks. The store’s
activity log records what changed as
plan_changed, with each field’s old and new value, the tier as
subscription_tier, by name, and subscription_tier_id, by ID, by the
member of the staff the request names, or by Easel; a plan as it was
changes nothing and logs nothing.
The call itself neither locks nor unlocks the store. A store the job locked stays locked after a payment is recorded here, until the caller calls Unlock Store; and a store the job found due to lock isn’t locked if its plan changes here before the job reaches it. Lock Store locks one at once.
Without Easel-Staff, as the tool billing stores calls it, any plan may
be set. A request naming a member of the staff may move the store onto
another tier only if they’re the operator, as for
Change Store Tier; anyone else is 403 Forbidden.
Staying on its tier, any member may set the rest.
Endpoint: PUT /api/platform/v1/stores/{store_id}/plan/
| Field | Type | Description |
|---|---|---|
tier_id |
string | Required. The tier it’s on |
billing_status |
string | Required. trialing, active, past_due, canceled, or free |
paid_until |
string|null | When the paid period ends, RFC 3339. Required for active, past_due, and canceled; must be null for trialing and free |
A tier_id naming no tier, or a paid_until its status doesn’t allow, is
400 Bad Request with INVALID_FIELD; a store there isn’t is
404 Not Found.
curl -X PUT "http://127.0.0.1:3011/api/platform/v1/stores/sto_d0b6db728q6vrspbvbqg/plan/" \
-H "Authorization: Bearer $EASEL_PLATFORM_TOKEN" \
-d '{"tier_id":"str_d0b5r2728q6vrspbv9n0","billing_status":"active","paid_until":"2026-11-30T17:40:12Z"}'
Answers with the store, as it stands now.
Suspend Store
Suspend a store for its content: its storefront goes offline, telling shoppers only that it’s unavailable, and its API stops answering. Its owners still sign in to read the decision and export their data, unless it’s suspended as illegal content of the gravest kind, which locks its dashboard too. Suspending a store suspended already changes why.
Endpoint: POST /api/platform/v1/stores/{store_id}/suspend/
| Field | Type | Description |
|---|---|---|
category |
string | Required. Why: illegal, csam, ip, fraud, hate, terms, or other |
explanation |
string | Required. Why, in words, which the store is told, up to 4,000 characters |
notice_id |
string | The open notice about the store the decision answers, whose reporter is told |
send_statement |
boolean | Whether to email the store the statement of reasons it’s owed. Default true; leave it out but when the police ask Easel not to alert the store |
Answers with the decision, whose activity log entry is
suspended.
Unsuspend Store
Lift a store’s suspension, undoing what suspending it did and no more: a
store locked for its plan stays locked. A store not suspended is
409 Conflict.
Endpoint: POST /api/platform/v1/stores/{store_id}/unsuspend/
| Field | Type | Description |
|---|---|---|
explanation |
string | Required. Why, up to 4,000 characters |
send_statement |
boolean | Whether to email the store the decision. Default true |
Answers with the decision.
Review Store
Mark a store reviewed, judged a legitimate business, or clear the mark so it lists as unreviewed again.
A reviewed store’s own content stops queueing for review: its products, collections, pages, site, images, and name, as it makes or changes them, are trusted as the business’s, which answers for them through notices. Its customers’ reviews still queue, as does any content a notice reports, and content queued before the mark stays queued. Clearing the mark queues the store’s content again as it changes.
Endpoint: POST /api/platform/v1/stores/{store_id}/reviewed/
| Field | Type | Description |
|---|---|---|
reviewed |
boolean | Required. true to mark it reviewed, false to clear the mark |
Answers 204 No Content.
Redeem Staff Access
Redeem the code a store’s owner read out to the member of the staff, for
the store alone, starting their access, and give them their way into its
dashboard. The store’s activity log records it as redeemed, by them.
Endpoint: POST /api/platform/v1/stores/{store_id}/staff-access/redeem/
| Field | Type | Description |
|---|---|---|
code |
string | Required. The code, eight digits, with or without the space between its halves |
A wrong code, or a store with none waiting, is 400 Bad Request; the
store’s code is voided after too many wrong guesses, 409 Conflict, and a
member who guesses too often is refused for up to 15 minutes,
429 Too Many Requests, with Retry-After: 900. A store whose access another member is using is
409 Conflict.
Response
sign_in_url signs the member in to the store’s dashboard once, within
two minutes, so the tool sends them straight to it. ends_at is when the
access ends.
{
"sign_in_url": "https://studio.easel.engineering/staff-sign-in/?token=...",
"ends_at": "2026-11-02T10:02:00Z"
}
Open Staff Access
Give the member of the staff using a store’s access a new way into its
dashboard, such as from another browser. The store’s activity log records
it as opened, by them. A store whose access they aren’t using is
409 Conflict.
Endpoint: POST /api/platform/v1/stores/{store_id}/staff-access/open/
Takes no body, and answers as Redeem Staff Access does.
Ban and Unban User
Shadow-ban a dashboard user, signing them out everywhere, or lift the ban.
The platform’s log records it as user_shadow_banned or
user_unbanned, by the member of the staff.
Endpoints: POST /api/platform/v1/users/{user_id}/ban/,
POST /api/platform/v1/users/{user_id}/unban/
Take no body, and answer 204 No Content.
Reset User Passkeys
Remove every passkey of a user who lost theirs and has no teammate to do
it, ending their sign-ins, and tell them by email. Their stores’ activity
logs and the platform’s log record it, by the member of the staff. A user
with no passkeys is 409 Conflict.
Endpoint: POST /api/platform/v1/users/{user_id}/reset-passkeys/
Takes no body, and answers with the user.
Delete User
Erase a dashboard user and their personal data, leaving their stores. The
platform’s log records it as user_deleted, naming the user by ID alone.
A user whose store’s team would be left without an owner is
409 Conflict.
Endpoint: DELETE /api/platform/v1/users/{user_id}/
Answers 204 No Content.
Review Moderation Item
Take an item off the review queue, judged fine, until its content changes or is reported.
Endpoint: POST /api/platform/v1/moderation/items/{item_id}/reviewed/
Takes no body, and answers 204 No Content.
Take Down
Take an item’s content down at once: an image stops being served, a
product becomes a draft its merchant can’t publish, and a collection,
page, or review is hidden. The decision answers the item’s open notices.
Taking an image down as csam also moves its files out of reach and
blocks them from being uploaded again, and suspends the store.
Endpoint: POST /api/platform/v1/moderation/items/{item_id}/take-down/
Takes category, explanation, notice_id, and send_statement, as
Suspend Store does. An item of a kind that can’t be
taken down, such as a store’s name, is 400 Bad Request, and an image
removed already, but for csam, 409 Conflict. Answers with the
decision, whose activity log entry is taken_down.
Restore
Put back content taken down. A product stays a draft and a review hidden
until the merchant publishes or shows them again. Content not taken down,
or an image removed as csam, which is never restored, is 409 Conflict.
Endpoint: POST /api/platform/v1/moderation/items/{item_id}/restore/
Takes explanation and send_statement, as
Unsuspend Store does, and answers with the decision.
Dismiss Notice
Answer a notice without acting on it, telling its reporter why. A notice
answered already is 409 Conflict.
Endpoint: POST /api/platform/v1/moderation/notices/{notice_id}/dismiss/
| Field | Type | Description |
|---|---|---|
explanation |
string | Required. Why, which the reporter is told, up to 4,000 characters |
Answers with the decision, whose action is no_action.
Add Staff
Add a member of Easel’s staff. Easel keeps no credentials for them: the
tool calling the API keeps those itself. The platform’s log records it as
staff_added.
Endpoint: POST /api/platform/v1/staff/
| Field | Type | Description |
|---|---|---|
username |
string | Required. Lowercase letters, digits, dots, dashes, and underscores, up to 64 of them. A username taken is 409 Conflict |
name |
string | Required. What stores’ logs call them, up to 128 characters |
Answers 201 Created with the member.
Disable and Enable Staff
Stop a member of the staff acting, ending every access they’re using, or
let them act again. Members are never deleted, so the logs keep naming
them. The operator can’t be disabled, 400 Bad Request. The platform’s
log records staff_disabled or staff_enabled.
Endpoints: POST /api/platform/v1/staff/{staff_id}/disable/,
POST /api/platform/v1/staff/{staff_id}/enable/
Take no body, and answer 204 No Content.
Create Tier
Add a plan stores may be moved onto. The platform’s log records it as
tier_created, by the operator.
Endpoint: POST /api/platform/v1/tiers/
| Field | Type | Description |
|---|---|---|
name |
string | Required. |
is_default |
boolean | Whether new stores start on it, in place of the tier they did |
curl -X POST "http://127.0.0.1:3011/api/platform/v1/tiers/" \
-H "Authorization: Bearer $EASEL_PLATFORM_TOKEN" \
-H "Easel-Staff: stf_d0b6cq728q6vrspbvb10" \
-d '{"name":"Studio","is_default":false}'
Answers 201 Created with the tier.
Change Tier
Replace a tier, with the fields Create Tier takes. The
whole tier is replaced, so a field left out takes its zero value: an
omitted is_default is false. The platform’s log records it as
tier_updated, by the operator.
Endpoint: PUT /api/platform/v1/tiers/{tier_id}/
One tier is always the default, which new stores start on. Making a tier
the default takes it from the tier that had it; taking it from the
default tier itself, rather than making another the default, is
409 Conflict.
Answers with the tier.
Redeem Billing Handoff
Redeem the token a store’s dashboard handed one of its users to the
billing pages with, once, and say who they are and for which store. The
dashboard’s billing button, which only an owner has, makes the token and
sends the user to {EASEL_BILLING_URL}/billing/handoff/?token=<token>,
even while the store is locked. A token works once, for five minutes.
EASEL_BILLING_URL is the billing pages’ origin. Only platform mode takes
it, and production needs it on https. The dashboard’s
Content-Security-Policy lets its forms post there; without it, the
dashboard shows no plan notices or billing button.
Endpoint: POST /api/platform/v1/billing-handoffs/redeem/
| Field | Type | Description |
|---|---|---|
token |
string | Required. The token, as the billing pages got it |
A token that was never made, or long expired, is 404 Not Found; one used
already, expired, for a store since deleted, or whose user has since left
the store or may no longer manage its billing, 410 Gone.
curl -X POST "http://127.0.0.1:3011/api/platform/v1/billing-handoffs/redeem/" \
-H "Authorization: Bearer $EASEL_PLATFORM_TOKEN" \
-d '{"token":"..."}'
Response
language is the language the user reads the dashboard in.
{
"store_id": "sto_d0b6db728q6vrspbvbqg",
"store_name": "Alder Pottery",
"user_id": "usr_d0b6db728q6vrspbvbr0",
"name": "Ana",
"email": "ana@example.com",
"language": "en"
}