A pass is a boolean. A feature unlock is another boolean. A receipt leaves no stored flag. A session is the mode that keeps a time: after pay succeeds, sessionValidUntil holds one uint64, and isSessionActive compares that timestamp with block.timestamp.

What mode 0 writes
PaymentMode.SESSION is 0. In _processPaymentMode, the session branch is the one that returns a timestamp:
if (mode == uint8(PaymentMode.SESSION)) {
validUntil = _grantSession(q.payer, q.appId, q.productId, q.duration);
}_grantSession writes sessionValidUntil[payer][appId][productId]. The public views sessionValidUntil and getSessionExpiration return that uint64. isSessionActive is true when the stored value is greater than or equal to block.timestamp. An empty slot reads as 0, so the check is false.
The Paid event still fires. For a session, mode is 0 and validUntil is the timestamp just stored. Feature, receipt, and pass payments leave validUntil at 0, because only the session branch assigns it. A pass is a boolean the contract keeps is the pass path. This post is the session path.
The slot is the product id

The mapping key is the payer, the bytes32 app id, and the product id. _grantSession reads q.productId. The quote still signs entitlementId, taken from product.entitlement_id or from productId when that column is null, and the Paid event still carries it. Two session products that share an entitlement id keep two timestamps. The same product id under another appId is a different slot. Another payer on the same app and product stays inactive.
The entitlement tests pay one product id into two apps and expect both isSessionActive calls to return true. A payment by one payer leaves that same app and product inactive for a second payer. Check isSessionActive with the product id stored as product_id and the app id stored as app_id. The product id is keccak256 of the app name, a colon, and the product name.
A renewal keeps the time that is left

_grantSession reads the current timestamp. When that value is still ahead of block.timestamp, the next duration is added to it. When the slot is empty or already behind the block, the next duration is added to block.timestamp.
uint64 currentExpiration = $.sessionValidUntil[payer][appId][productId];
uint64 baseTime = currentExpiration > block.timestamp
? currentExpiration
: uint64(block.timestamp);
unchecked {
validUntil = baseTime + duration;
}The renewal test pays a duration of 3600, advances 1800 seconds, and pays 3600 again. The second sessionValidUntil is greater than the first. The half hour that was left stays in the sum. The new payment transfers tokens again. The previous quoteId is already used, and a replay reverts with QuoteIdAlreadyUsed.
After the stored time, nothing has to clear the slot. The entitlement test advances duration + 1 seconds and expects isSessionActive to be false. The old timestamp stays in storage. The next payment uses block.timestamp as the base, so expired time is left behind.
At the exact second the stored timestamp equals block.timestamp, isSessionActive is still true, because the check is >=. A renewal in that same second uses block.timestamp as the base. That value equals the stored timestamp, so the new end is the old end plus the new duration.
A signed duration of 0 still lets pay succeed. The zero-duration test expects sessionValidUntil to equal the block at payment, isSessionActive to be true in that second, and false after one more second. The dashboard requires a duration for session mode and rejects an empty field. The admin create route accepts a positive integer and stores null when duration is omitted. POST /api/quote copies product.duration, or 0 when that value is null. A null duration is the zero-duration case.
The create form converts the chosen unit to seconds before save. A minute is 60, an hour is 3600, a day is 86400, a week is 604800, a month is 2592000, and a year is 31536000.
The quote clocks are not the session length

POST /api/quote 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. The session length is q.duration, copied from the product. It starts when pay succeeds, from the block or from a timestamp that is still ahead.
Waiting inside the deadline does not shorten the session. Once deadline is behind the block, that quote cannot be submitted. Ask for a new quote and pay that one.
A session cannot take a custom amount. Sending amount on the quote returns Custom amounts are only allowed for RECEIPT mode 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 session, and it stores dollars times 1000000.
A purchase that should leave proof and no stored time is a receipt. See Let a receipt take the amount the buyer names. A membership that should stay true with no clock is a pass.
Read isSessionActive after the same checkout

useTokenPayment and usePayment do not branch on mode. They load the product price, approve the token, request the quote, and submit pay. For a session, omit amount. The quote route rejects it. The walkthrough is From wallet connect to entitlement, in two React hooks.
@payxor/react exports useSessionStatus. It calls client.isSessionActive and polls. The default interval is 5000 milliseconds. The hook returns isActive, loading, and error. isActive stays null until address, chainId, and the client are set, and it stays null when enabled is false. The hook returns the boolean. The contract views sessionValidUntil and getSessionExpiration return the timestamp. The SDK method behind the hook is isSessionActive.
import { useSessionStatus } from "@payxor/react";
const { isActive, loading, error } = useSessionStatus({
apiUrl: "https://api.payxor.xyz",
walletClient,
appId,
productId,
address,
chainId,
});usePayXorClient returns null until both walletClient and apiUrl are set. The same read is isSessionActive on @payxor/sdk, and check_session on @payxor/mcp-client. That tool takes payer, the bytes32 appId, the bytes32 productId, and chainId. It does not take a key. See From a question to a signed quote, in two MCP servers.
Create the session from the dashboard, set the duration in seconds, pay it, and gate the screen on isSessionActive for that product id.