Trial
Read the trial status of a single subscription contract. This endpoint is read-only and carries no PII.
Endpoint
GET /subscriptions/{contractId}/trialAll paths are relative to the base URL https://<your-app-domain>/storefront-api/v1.
Authentication
Like every Storefront API endpoint (except /__health), this requires both auth layers:
X-Joy-App-Id: <app-id>
X-Joy-Secret-Key: sk_live_xxx # server-side only
X-Session-Id: <session-id>The shopId is always derived from the session. shopId is never read from a header.
Path parameters
| Name | Type | Required | Notes |
|---|---|---|---|
contractId | number | yes | The numeric subscriptionContractId. |
Note:
contractIdis validated inline in the controller. A non-numeric or missing id returns422 VALIDATION_FAILED— this is the only endpoint that uses422.
A contract that does not exist, or that is not owned by the authenticated customer, returns 404 NOT_FOUND (ownership is enforced to prevent IDOR; existence is not leaked).
Request
curl -s "https://<your-app-domain>/storefront-api/v1/subscriptions/123456789/trial" \
-H "X-Joy-App-Id: <app-id>" \
-H "X-Joy-Secret-Key: sk_live_xxx" \
-H "X-Session-Id: <session-id>"Response
{
"success": true,
"data": {
"contractId": 123456789,
"hasTrial": true,
"isInTrial": true,
"trialEndsAt": "2026-07-10T00:00:00.000Z",
"daysRemaining": 16,
"trialProcessed": false,
"afterAction": "continue",
"status": "ACTIVE"
}
}Response fields
| Field | Type | Description |
|---|---|---|
contractId | number | The numeric subscriptionContractId. |
hasTrial | boolean | Whether the contract has a trial configured. |
isInTrial | boolean | Whether the contract is currently within its trial window. |
trialEndsAt | string | null | ISO 8601 timestamp when the trial ends. |
daysRemaining | number | null | Days remaining in the trial. null when the contract has no trial (hasTrial is false). |
trialProcessed | boolean | Whether the trial transition has already been processed. |
afterAction | string | null | What happens when the trial ends: pause | continue | null. |
status | string | Contract status: ACTIVE | PAUSED | CANCELLED | EXPIRED | FAILED | PENDING | DECLINED. |
Errors
This endpoint uses the standard error envelope:
{ "success": false, "error": { "code": "NOT_FOUND", "message": "..." } }| HTTP | Code | When |
|---|---|---|
| 401 | INVALID_APP_CREDENTIALS | App ID / Secret Key missing or invalid. |
| 401 | AUTH_REQUIRED | Session header missing. |
| 401 | SESSION_EXPIRED | Session is no longer valid. |
| 403 | SHOP_MISMATCH | App shop and session shop differ. |
| 404 | NOT_FOUND | Contract not found or not owned by the customer. |
| 422 | VALIDATION_FAILED | contractId is non-numeric or missing. |
| 429 | RATE_LIMITED | A rate-limit dimension was exceeded. |
| 500 | INTERNAL_ERROR | Unexpected server error (generic message). |
Rate limits
This is a read (GET) request, so it counts against the ip, customer, and shop dimensions (not the write dimension). Exceeding any one of them returns 429 RATE_LIMITED.
| Dimension | Limit | Window (s) | Burst | Scope |
|---|---|---|---|---|
| ip | 120 | 60 | 20 | Per IP (per-shop after auth). |
| customer | 60 | 60 | 10 | Per authenticated customer. |
| shop | 600 | 60 | 60 | Per shop. |
On a 429, the response includes the headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset, plus retryAfterSeconds and scope in the body. Integrate against the error code, scope, and retryAfterSeconds rather than the human-readable message.
Related webhooks
Trial transitions are also emitted as outbound webhooks (Joy to your endpoint) from the trial cron job. The topics are custom to Joy:
| Topic | Fired when |
|---|---|
trial/ending | A trial is approaching its end. |
trial/ended | A trial has ended. |