PayXor
Back to Blog

A feature is a boolean on its entitlement

Mode 1 sets featureUnlocked for one payer, one app, and one entitlement id. isFeatureUnlocked stays true, and it never reads the block.

November 2, 2026•PayXor Team

A session keeps a timestamp on the product id. A pass keeps a boolean on its pass id. A receipt leaves no stored flag. A feature is the other permanent boolean: after pay succeeds, isFeatureUnlocked is true for that payer, that app, and that entitlement id.

A frosted-glass tile on a dark navy field, with a cyan latch and a thin gold edge.

What mode 1 writes

PaymentMode.FEATURE is 1. In _processPaymentMode, the session branch returns a timestamp, the pass branch calls _grantPass, and a receipt writes nothing. The feature branch only calls _grantFeature:

} else if (mode == uint8(PaymentMode.FEATURE)) {
    _grantFeature(q.payer, q.appId, q.entitlementId);
}

_grantFeature sets one boolean:

$.featureUnlocked[payer][appId][entitlementId] = true;

The third key is entitlementId. Session storage uses productId in that position. The public view is isFeatureUnlocked(payer, appId, entitlementId). It returns the stored boolean. The public getter featureUnlocked returns the same value. Neither view reads block.timestamp. An empty slot reads as false.

The Paid event still fires. For a feature, mode is 1 and validUntil is 0, because only the session branch assigns validUntil. The event carries entitlementId, productId, amount, and a receiptHash. There is no separate unlock event.

A pass is a boolean the contract keeps is the pass path. A session is a timestamp the contract keeps is the session path. Understanding PayXor's Four Payment Modes is the short map. This post is the feature path.

The slot is the entitlement id

Three frosted-glass niches on a dark navy field, holding a wallet, an app tile, and a key, joined by one cyan line.

The quote route sets entitlementId to product.entitlement_id, and falls back to productId when that column is null. The product id is keccak256 of the app name, a colon, and the product name. The dashboard create form sends no entitlement id, so a feature created there is keyed by its product id.

getProductInfo returns entitlementId or null. The id you pass to isFeatureUnlocked is that value when it is set, and the product id when it is not. That is the same fallback the quote uses. Check the bytes32 app id, the one stored as app_id, not the app UUID from the dashboard URL.

productId is still on the quote and on the Paid event. It is not part of the feature slot. The entitlement tests pay two entitlement ids, reusing one product id, and expect both isFeatureUnlocked calls to return true. When a quote signs a custom entitlement id, reading isFeatureUnlocked with the product id returns false. The flag was stored under the entitlement id.

The mapping also includes appId and payer. The same entitlement id under another app id is a different slot, and it stays false until a quote for that app is paid. The flag is written for q.payer. An address that has not paid reads false. A second payer unlocks the same entitlement id by paying their own quote.

Two frosted-glass tiles pouring cyan light into one shared slot, beside a third tile with its own spark.

To make two products share one unlock, set the same entitlementId on both before either is created. POST /api/admin/products and create_product on @payxor/mcp-admin take that field. Admin calls take the app UUID. An omitted entitlement id is stored as null and does not become the zero hash. If you do store the zero hash, isFeatureUnlocked is true for bytes32(0) after payment. Leaving the field empty does not do that.

After create, product_id, mode, and entitlement_id cannot be changed. The update route rejects those fields. A shared id has to be chosen on the create call.

A pass uses the same quote field and a different mapping. hasPass will not see a feature unlock, and isFeatureUnlocked will not see a pass, even when the bytes32 id matches. Buy the product whose mode is 1 if the check you ship is isFeatureUnlocked.

A stored duration does not clear the flag

A frosted-glass tile with a cyan point of light in front of a dark, unlit clock.

The dashboard create form shows a duration field for a session, and a membership-type choice for a pass. Feature mode has neither. The create call omits duration, and the row stores null.

POST /api/admin/products still accepts an optional positive duration for any mode, and the update route can change duration later. POST /api/quote copies product.duration onto the signed quote, or 0 when the duration is null. _grantFeature never reads q.duration. isFeatureUnlocked stays true. The signed duration does not become an expiry.

POST /api/quote also sets expiresAt to one hour from now and deadline to thirty minutes from now. pay reverts with QuoteExpired when expiresAt is less than block.timestamp, and with DeadlinePassed when deadline is less than block.timestamp. Those checks gate the signature. They do not clear featureUnlocked. After the deadline, ask for a new quote. Once pay has succeeded, the boolean does not follow either clock.

There is no revoke function on the contract. Paying again needs a new quote. The previous quoteId is already used, and a replay reverts with QuoteIdAlreadyUsed. The entitlement test pays the same entitlement id a second time with a new quoteId. The new payment transfers tokens again and leaves the same boolean true.

A feature cannot take a custom amount. Sending amount on the quote returns Custom amounts are only allowed for RECEIPT mode products. Setting allowsCustomAmount on a mode 1 product returns allowsCustomAmount can only be enabled for RECEIPT mode (mode 2) products. The signed amount is product.price. A price of "0" signs zero, and pay reverts with InvalidAmount. The dashboard price field is required for a feature, and it stores dollars times 1000000.

A purchase that should end is a session. A membership flag is a pass. A purchase that should leave proof and no stored flag is a receipt. See Let a receipt take the amount the buyer names.

Read isFeatureUnlocked after the same checkout

A frosted-glass wallet facing a panel that holds one cyan check, with dark blocks behind the panel.

useTokenPayment and usePayment do not branch on mode. They load the product price, approve the token, request the quote, and submit pay. For a feature, omit amount. The quote route rejects it. The walkthrough is From wallet connect to entitlement, in two React hooks.

useFeatureStatus polls client.isFeatureUnlocked and returns { isUnlocked, loading, error }. isUnlocked is null without an address, a chain id, and a client, or when enabled is false. usePayXorClient returns null until both walletClient and apiUrl are set. The default poll is every 5000 ms while the flag is not true. After a read comes back true, the interval is cleared. A pollInterval of 0 runs the first check and does not schedule another. The hook does not return a timestamp.

import { useFeatureStatus } from "@payxor/react";

const { isUnlocked, loading, error } = useFeatureStatus({
  apiUrl: "https://api.payxor.xyz",
  walletClient,
  appId,
  entitlementId: product.entitlementId ?? productId,
  address,
  chainId,
});

client.isFeatureUnlocked calls the contract view isFeatureUnlocked. The same read is isFeatureUnlocked on @payxor/sdk, and check_feature on @payxor/mcp-client. That tool takes payer, the bytes32 appId, the bytes32 entitlementId, and chainId. It does not take a key. See From a question to a signed quote, in two MCP servers.

Create the feature from the dashboard, pay it once, and gate the screen on isFeatureUnlocked for the entitlement id rather than on the transaction hash alone.