Commerce
Commerce-related fields surfaced by storefront endpoints, plus the in-app purchase redirect that turns a purchase_link into a completed transaction.
Storefront Mode (scope=all)
The library, library filter, and store search endpoints accept an optional scope query parameter:
| Value | Behavior |
|---|---|
owned | Default. Returns only content the user already has access to. Commerce fields are not included. |
all | Storefront mode. Returns all eligible content (owned and unowned), with commerce fields per issue. |
scope=all is what Fenice uses to render the in-app store. The same response shape that powers the user's library is reused for browsing purchasable content.
Endpoints that support scope=all:
GET /api/v2/library?scope=allGET /api/v2/library/filter?scope=allGET /api/v2/search/store?scope=all
scope=all is a single-tenant concept and only works on endpoints that already require X-Farfalla-Tenant-Id. Multi-tenant endpoints (e.g. /user/my-content, /search/global) ignore the parameter and never include commerce fields, because pricing and purchase URLs depend on tenant context.
Commerce Fields
When scope=all is set, every issue object in the response is augmented with the following fields:
{
"id": 153598,
"tenant_id": 42,
"name": "The Governor and the Rebel",
"slug": "the-governor-and-the-rebel",
"cover_url": "https://cdn.publica.la/issues/cover.jpg",
"type": "epub",
"...": "...",
"can_be_read": false,
"can_be_bought": true,
"access_reason": null,
"free": false,
"prices": [
{
"currency": "USD",
"amount": 9.99
}
],
"purchase_link": "https://{store_final_domain}/app/purchase/153598",
"status": "Acquire"
}
| Field | Type | Description |
|---|---|---|
can_be_read | boolean | Whether the user already has access to read this issue |
can_be_bought | boolean | Whether this issue is available for purchase (false if already owned or free) |
access_reason | string or null | Why the user has access. One of free, purchased, subscription. Null when the user does not have access. See Access Reason below |
free | boolean | Whether the issue is freely available without any transaction |
prices | array | Price list. Currently contains only the tenant's main currency. See Prices Array below |
purchase_link | string or null | Base URL to start the in-app purchase flow. The app appends ?token={jwt}&app_tenant_id={id} at click time. See Purchase Redirect below |
status | string | Localized user-facing label: "Acquire", "Read", "Listen", etc. Use this to render the action button |
Access Reason
When can_be_read is true, access_reason indicates why the user has access:
| Value | Meaning |
|---|---|
free | The issue is free (free_issue) or the tenant has free access enabled (free_access) |
purchased | The user acquired the issue: bought, gifted, assigned by admin, retail license, or external permissions |
subscription | The user has access via a subscription plan (global, with collections, external, or entry-point based) |
null | The user does not have access (can_be_read is false) |
Prices Array
The prices field is an array of price objects. Currently returns only the tenant's main currency; the array format allows future multi-currency support without breaking the contract.
Each price object:
| Field | Type | Description |
|---|---|---|
currency | string | ISO 4217 currency code (e.g. "USD") |
amount | float | Base price in major units (e.g. 9.99) |
discount | object | Present only when a promotional price is active |
discount.amount | float | Discounted price the user pays (e.g. 4.99) |
discount.percentage | float / null | Discount percentage (e.g. 50.0) |
discount.ends_at | string / null | When the promotion ends (ISO 8601 datetime), null if no expiry |
Without discount:
"prices": [
{ "currency": "USD", "amount": 9.99 }
]
With active promotion:
"prices": [
{
"currency": "USD",
"amount": 9.99,
"discount": {
"amount": 4.99,
"percentage": 50.0,
"ends_at": "2025-08-01 00:00:00"
}
}
]
Free issue or no prices configured:
"prices": []
When rendering prices, iterate the array. If an item has a discount key, show discount.amount as the current price and amount as the strikethrough original. The array will contain at most one element per currency.
Content Visibility
scope=all only returns content that is individually acquirable: issues that are free or have individual prices set. Subscription-only content (no individual prices, not free) is excluded from the results. This ensures every item returned can be meaningfully acted upon by the consumer.
Status Label Logic
The status field encodes the next action the user can take on the issue:
| Condition | Status (English) |
|---|---|
| User has access and content is audio | Listen |
| User has access and content is not audio | Read |
| User does not have access | Acquire |
Labels are translated based on the user's locale.
Purchase Redirect
GET /app/purchase/{issue_id}
Hybrid endpoint that bridges the JWT-authenticated mobile app with the cookie-based web checkout. The Fenice app opens this URL in a WebView (or external browser) and the server orchestrates the entire purchase flow:
- Validates the JWT bearer/query token, extracts the user email
- Finds or creates the user in the tenant (resolved automatically from the request domain)
- Logs the user in (web session + cookie)
- Stores the return deeplink in the session
- Clears the cart and adds the issue to it
- Returns a
302redirect to the web checkout
When the user completes the checkout, the server detects the stored return deeplink and redirects to it instead of rendering the standard web thank you page, so the app reopens automatically and can refresh the user's library.
This endpoint is not under the /api/v2/ prefix because it needs to operate on the web middleware stack (cookies, sessions, full Livewire flow). It is served from the same domain as the rest of the API.
Client Integration
The purchase_link from storefront responses is a base URL. The app must append parameters before opening it:
token(required): the user's current JWT.app_tenant_id(only for custom branded apps): the tenant ID that owns the app (the same value sent in theX-CustomFenice-Tenant-Idheader), not necessarily the tenant that owns the content being purchased. Only send this when the calling app is a custom branded app (e.g. Bajalibros). Without it, the post-purchase deeplink defaults topublicala://instead of the custom app's scheme, and the user won't return to the correct app. For the generic publica.la Reader, omit this parameter.
{purchase_link}?token={current_jwt}&app_tenant_id={app_tenant_id}
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
issue_id | path | integer | Yes | ID of the issue to purchase |
token | query | string | Yes | JWT token for user authentication |
app_tenant_id | query | integer | Yes for custom branded apps | Tenant ID that owns the calling app. Used to resolve the post-purchase deeplink scheme. Custom branded apps must always send this. |
Request Example
# The app appends token and app_tenant_id to the purchase_link at click time
curl -L "https://{store_final_domain}/app/purchase/153598?token=eyJhbG...&app_tenant_id=42"
Response
302 Found
Location: https://{store_final_domain}/cart/checkout?automatically_open_checkout=true
The body is empty. The client should follow the redirect inside the WebView to land on the checkout page.
Post-Purchase Deeplink
After a successful payment, the user is redirected to:
{resolved_scheme}purchase-complete?issue_id={issue_id}&status=success
The scheme is resolved server-side based on app_tenant_id:
- No
app_tenant_id(orapp_tenant_id=1): usespublicala://(publica.la Reader) app_tenant_idmatches the current tenant id or itsaggregator_id: uses the scheme attenants_meta.mobile_app.schemeon the tenant with id =app_tenant_id(the family owner), e.g.bajalibros-pla://- Otherwise: uses
publicala://
For the default app:
publicala://purchase-complete?issue_id=153598&status=success
For a custom app (tenant 42 with scheme bajalibros-pla://):
bajalibros-pla://purchase-complete?issue_id=153598&status=success
The mobile app should register this deeplink path in its navigation linking config to handle the return and refresh the user's library.
Error Responses
| Code | Cause |
|---|---|
| 401 | Missing token, invalid signature, or expired JWT |
| 404 | Issue not found in this tenant |
| 422 | Issue is free, uses pay-per-use licensing, or has no prices |
| 429 | Rate limit exceeded (60 requests per minute per IP) |