PayXor
Back to Blog

A pass is a boolean the contract keeps

Mode 3 sets hasPass for one payer, one app, and one pass id. That id is the product entitlement, or the product id when the entitlement is empty.

October 26, 2026•PayXor Team

A session ends. A feature unlock stays on its own flag. A receipt leaves no record the contract can answer later. A pass is the other permanent flag: after pay succeeds, hasPass is true for that payer, that app, and that pass id.

A frosted-glass membership card on a dark navy field, with a cyan seal and a thin gold edge.

What mode 3 writes

PaymentMode.PASS is 3. In _processPaymentMode, a session writes a validUntil timestamp, a feature sets featureUnlocked, and a receipt writes nothing. The pass branch only calls _grantPass:

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

_grantPass sets one boolean:

$.hasPassMapping[payer][appId][passId] = true;

The public view is hasPass(payer, appId, passId). It reads that boolean. It does not look at block.timestamp. The Paid event still fires. For a pass, mode is 3 and validUntil is 0, because only the session branch assigns validUntil. The event also carries entitlementId, amount, and a receiptHash. There is no separate pass event.

A second product with a different pass id is a different slot. The entitlement tests grant two pass ids to the same payer and expect both hasPass calls to return true. The same pass id under another appId stays false. Understanding PayXor's Four Payment Modes is the short map. This post is the pass path.

The pass id is the entitlement id

A geometric wallet, an app-window tile, and a small card, each in its own frosted-glass niche, 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 pass created there is keyed by its product id.

getProductInfo returns entitlementId or null. The id you pass to hasPass 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.

To make two products share one membership, set the same entitlementId on both. 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, hasPass is true for bytes32(0) after payment. Leaving the field empty does not do that.

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

A feature uses the same quote field and a different mapping. isFeatureUnlocked(payer, appId, entitlementId) will not see a pass, and hasPass will not see a feature unlock. Buy the product whose mode is 3 if the check you ship is hasPass.

A duration on the product does not clear the flag

A frosted-glass card with a cyan seal in front of a dark, unlit clock.

The create-product form labels the choice Membership Type, with Permanent membership and Duration-based membership. Permanent leaves duration null. Duration-based stores a positive number of seconds. The form treats a month as 2592000 seconds and a year as 31536000.

POST /api/quote copies product.duration onto the signed quote, or 0 when the duration is null. _grantPass never reads q.duration. hasPass stays true. The signed duration does not become an expiry.

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 new payment transfers tokens again and leaves the same boolean true.

A membership that should end is a session. Session mode stores validUntil, and isSessionActive is true only while that timestamp is still at or ahead of block.timestamp. A purchase that should leave proof and no stored flag is a receipt. See Let a receipt take the amount the buyer names.

A pass 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 pass, and it stores dollars times 1000000.

Read hasPass after the same checkout

A geometric wallet facing a frosted-glass 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 pass, omit amount. The quote route rejects it. The walkthrough is From wallet connect to entitlement, in two React hooks.

@payxor/react exports useSessionStatus and useFeatureStatus. It does not export a pass hook. After the payment confirms, read the flag from the client those hooks already build. usePayXorClient returns null until both walletClient and apiUrl are set.

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

const client = usePayXorClient({
  apiUrl: "https://api.payxor.xyz",
  walletClient,
});

async function payerOwnsPass() {
  if (!client || !address || !chainId) return false;
  const passId = product.entitlementId ?? productId;
  return client.hasPass(address, appId, passId, chainId);
}

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

Create the pass from the dashboard, pay it once, and gate the screen on hasPass rather than on the transaction hash alone.