A customer pays for 30 days of access to a private Telegram community.
The payment reaches paid, but no invitation arrives.
Another customer receives a link, forwards it to a friend, and the wrong person joins first.
A renewal payment is processed twice, extending access by 60 days instead of 30.
An expired member remains inside the group because the removal job failed silently.
The payment bot works.
The membership system does not.
A Telegram Paid Access System must connect four independent processes:
Payment
Membership term
Telegram access
Operational support
It must prove:
- which Telegram user purchased access
- which payment belongs to that user
- which plan and community were purchased
- whether the payment was verified
- whether access was granted
- whether the expected user joined
- when the membership expires
- whether renewal was applied exactly once
- whether expired access was actually removed
- what support should do when any step fails
This article uses OxaPay as the crypto payment infrastructure and Telegram's Bot API as the access-control layer.
The architecture is specific enough to build, but the underlying principles apply to other payment providers and community platforms.
This article is part of 10 Crypto Payment Products Developers Can Build for Merchants.
The product is not the bot
A basic bot can:
Show plans
-> Create payment link
-> Send invite after payment
That is enough for a demonstration.
A merchant-facing product must handle the entire access lifecycle:
User identity
-> Plan selection
-> Checkout session
-> Verified payment
-> Membership term
-> Telegram join approval
-> Active access
-> Renewal
-> Expiry
-> Revocation
-> Recovery
The merchant is not buying:
A Telegram bot with a crypto button.
The merchant is buying:
A system that gives paid users access, prevents unpaid users from entering, removes expired members, and explains every payment and membership incident.
That distinction defines the architecture.
Telegram already supports Stars subscriptions
Telegram's Bot API includes createChatSubscriptionInviteLink for channel subscriptions paid with Telegram Stars.
That can be the right solution when:
- Telegram Stars are acceptable
- the product is a channel subscription
- a fixed 30-day subscription period fits the business
- the merchant wants payment handled natively inside Telegram
This article solves a different problem.
A custom OxaPay-based system is useful when the merchant specifically needs:
- cryptocurrency invoices
- external payment records
- custom access durations
- several plan types
- stablecoin or cryptocurrency payments
- access across multiple groups or channels
- merchant-owned billing and reporting
- external CRM, support, or finance integrations
Do not build custom infrastructure when Telegram Stars already satisfy the complete merchant requirement.
Build it when the merchant needs a crypto-specific operational layer.
Separate payment, membership, and access
Do not store everything in one field such as:
is_paid = true
A real system needs separate states.
Payment state
created
waiting
confirming
paid
underpaid
expired
refunded
review_required
Subscription state
pending
active
grace_period
expired
revoked
canceled
Telegram access state
not_requested
invite_pending
join_requested
approved
member
removal_pending
removed
failed
These combinations are valid:
Payment: paid
Subscription: active
Telegram access: failed
The customer paid, but the system still owes access.
Another valid state:
Payment: expired
Subscription: active
Telegram access: member
The renewal invoice expired, but the currently paid membership term has not ended.
An expired renewal checkout should not remove access before the existing term expires.
Define the business invariants
Before writing code, define the rules that must always hold.
Useful invariants include:
Every checkout belongs to one Telegram user and one plan.
Every accepted payment creates at most one membership term.
A membership term cannot begin from an unverified payment.
Access is approved only for the Telegram user attached to the checkout.
A forwarded invite must not grant another user access.
Every active Telegram member must have an active term or an authorized manual grant.
Every expired term must eventually produce a verified removal outcome.
Every manual extension or revocation must have an audit record.
A payment can remain paid even when Telegram access delivery fails.
These rules matter more than the bot's interface.
Recommended access model
Telegram gives you two practical invite strategies.
Option one: limited invite link
Create an additional invite link with:
member_limit = 1
short expiry
unique internal name
This is simple, but it does not guarantee that the intended user joins.
Whoever uses the link first may consume it.
This model may be acceptable for:
- low-value communities
- simple prototypes
- merchants who accept the forwarding risk
- workflows with manual join verification
It is not the strongest identity-control model.
Option two: join-request approval
Create an invite link with:
creates_join_request = true
When someone opens the link, Telegram sends a chat_join_request update.
Your backend then compares:
Requested Telegram user ID
Expected Telegram user ID
Community
Active membership term
Invite record
The bot approves the request only when all evidence matches.
This is the recommended model for paid access.
Telegram does not allow member_limit and creates_join_request on the same invite link. Choose one access model deliberately.
The system architecture
+----------------------+
| Telegram User |
+----------+-----------+
|
| /start and plan selection
v
+----------------------+
| Telegram Bot |
+----------+-----------+
|
| Create local checkout
v
+----------------------+
| Paid Access Backend |
+----------+-----------+
|
| Generate invoice
v
+----------------------+
| OxaPay |
+----------+-----------+
|
| Signed payment callback
v
+----------------------+
| Payment Webhook |
+----------+-----------+
|
| Verify and persist
v
+----------------------+
| Event + Outbox |
+----------+-----------+
|
v
+----------------------+
| Membership Worker |
+----------+-----------+
|
| Create one membership term
v
+----------------------+
| Access Worker |
+----------+-----------+
|
| Create join-request link
v
+----------------------+
| Telegram Join Request|
+----------+-----------+
|
| Verify Telegram user ID
v
+----------------------+
| Approve Access |
+----------+-----------+
|
v
Renewal / Expiry / Revocation / Support
Payment processing and Telegram actions should not run directly inside the OxaPay webhook request.
The webhook endpoint should:
- Identify the merchant.
- Preserve the raw request body.
- validate the HMAC signature.
- Store the payment event.
- Create an outbox job.
- Return HTTP
200withok.
Membership activation and Telegram API calls belong in background workers.
A practical data model
CREATE TABLE merchants (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
oxapay_merchant_api_key_encrypted TEXT NOT NULL,
telegram_bot_token_encrypted TEXT NOT NULL,
webhook_endpoint_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE communities (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
name TEXT NOT NULL,
telegram_chat_id BIGINT NOT NULL,
access_mode TEXT NOT NULL DEFAULT 'join_request',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, telegram_chat_id)
);
CREATE TABLE access_plans (
id UUID PRIMARY KEY,
community_id UUID NOT NULL REFERENCES communities(id),
code TEXT NOT NULL,
name TEXT NOT NULL,
price NUMERIC(20, 8) NOT NULL,
currency TEXT NOT NULL,
duration_days INTEGER NOT NULL,
grace_days INTEGER NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (community_id, code)
);
CREATE TABLE telegram_members (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
telegram_user_id BIGINT NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, telegram_user_id)
);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
community_id UUID NOT NULL REFERENCES communities(id),
telegram_member_id UUID NOT NULL REFERENCES telegram_members(id),
current_plan_id UUID REFERENCES access_plans(id),
status TEXT NOT NULL DEFAULT 'pending',
current_term_start TIMESTAMP,
current_term_end TIMESTAMP,
grace_until TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (community_id, telegram_member_id)
);
CREATE TABLE checkout_sessions (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
community_id UUID NOT NULL REFERENCES communities(id),
telegram_member_id UUID NOT NULL REFERENCES telegram_members(id),
subscription_id UUID REFERENCES subscriptions(id),
plan_id UUID NOT NULL REFERENCES access_plans(id),
purpose TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'oxapay',
provider_order_id TEXT NOT NULL UNIQUE,
provider_track_id TEXT,
provider_status TEXT,
internal_status TEXT NOT NULL DEFAULT 'created',
amount NUMERIC(20, 8) NOT NULL,
currency TEXT NOT NULL,
payment_url TEXT,
expires_at TIMESTAMP,
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (provider, provider_track_id)
);
CREATE TABLE payment_events (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
checkout_session_id UUID REFERENCES checkout_sessions(id),
provider TEXT NOT NULL,
payload_hash TEXT NOT NULL,
provider_track_id TEXT,
provider_status TEXT,
raw_payload JSONB NOT NULL,
signature_valid BOOLEAN NOT NULL,
source TEXT NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, payload_hash)
);
CREATE TABLE membership_terms (
id UUID PRIMARY KEY,
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
checkout_session_id UUID REFERENCES checkout_sessions(id),
plan_id UUID NOT NULL REFERENCES access_plans(id),
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
grant_type TEXT NOT NULL,
granted_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (checkout_session_id)
);
CREATE TABLE telegram_invites (
id UUID PRIMARY KEY,
community_id UUID NOT NULL REFERENCES communities(id),
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
membership_term_id UUID NOT NULL REFERENCES membership_terms(id),
expected_telegram_user_id BIGINT NOT NULL,
invite_link TEXT NOT NULL UNIQUE,
access_mode TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
expires_at TIMESTAMP NOT NULL,
joined_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE telegram_access_actions (
id UUID PRIMARY KEY,
community_id UUID NOT NULL REFERENCES communities(id),
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
action_type TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
request_data JSONB,
result_data JSONB,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP
);
CREATE TABLE outbox_jobs (
id UUID PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE access_cases (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
community_id UUID REFERENCES communities(id),
subscription_id UUID REFERENCES subscriptions(id),
checkout_session_id UUID REFERENCES checkout_sessions(id),
case_type TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
summary TEXT NOT NULL,
recommended_action TEXT,
resolution_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP
);
The key constraint is:
UNIQUE (checkout_session_id)
on membership_terms.
One accepted payment can create only one membership term.
Duplicate callbacks cannot extend access twice.
Store Telegram IDs as 64-bit values
Do not identify members by username.
Telegram usernames can be changed, removed, or reused.
The durable identity is:
telegram_user_id
Store it in a database type that supports 64-bit values.
Keep the username only as searchable display metadata.
Create the checkout locally first
When the user chooses a plan, create the local checkout before calling OxaPay.
import crypto from "node:crypto";
const OXAPAY_API = "https://api.oxapay.com/v1";
export async function createTelegramCheckout({
merchant,
community,
telegramMember,
plan,
purpose = "new_membership",
}) {
if (!plan.active) {
throw new Error("Access plan is unavailable");
}
const checkoutId = crypto.randomUUID();
const providerOrderId = `tg_${checkoutId}`;
const subscription = await db.subscription.upsert({
where: {
communityId_telegramMemberId: {
communityId: community.id,
telegramMemberId: telegramMember.id,
},
},
create: {
communityId: community.id,
telegramMemberId: telegramMember.id,
currentPlanId: plan.id,
status: "pending",
},
update: {},
});
const checkout = await db.checkoutSession.create({
data: {
id: checkoutId,
merchantId: merchant.id,
communityId: community.id,
telegramMemberId: telegramMember.id,
subscriptionId: subscription.id,
planId: plan.id,
purpose,
provider: "oxapay",
providerOrderId,
internalStatus: "created",
amount: plan.price,
currency: plan.currency,
},
});
try {
const merchantApiKey = await decryptSecret(
merchant.oxapayMerchantApiKeyEncrypted,
);
const response = await fetch(
`${OXAPAY_API}/payment/invoice`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
merchant_api_key: merchantApiKey,
},
body: JSON.stringify({
amount: plan.price,
currency: plan.currency,
lifetime: 60,
order_id: providerOrderId,
description:
`${plan.name} access for ${community.name}`,
callback_url:
`${process.env.APP_URL}/webhooks/oxapay/${merchant.webhookEndpointId}`,
return_url:
`${process.env.APP_URL}/telegram/checkouts/${checkout.id}`,
sandbox:
process.env.NODE_ENV !== "production",
}),
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`OxaPay invoice creation failed with ${response.status}`,
);
}
const payment = payload.data;
return db.checkoutSession.update({
where: { id: checkout.id },
data: {
providerTrackId: String(payment.track_id),
providerStatus: "new",
internalStatus: "invoice_created",
paymentUrl: payment.payment_url,
expiresAt: payment.expired_at
? new Date(Number(payment.expired_at) * 1000)
: null,
},
});
} catch (error) {
await db.checkoutSession.update({
where: { id: checkout.id },
data: {
internalStatus: "creation_failed",
},
});
throw error;
}
}
The frontend or bot must never choose the authoritative price.
Load the plan and price from your own database.
Send the invoice through the bot
A simplified Telegraf handler could look like this:
import { Markup, Telegraf } from "telegraf";
const bot = new Telegraf(
process.env.TELEGRAM_BOT_TOKEN,
);
bot.start(async (ctx) => {
await upsertTelegramMember(ctx.from);
await ctx.reply(
"Choose your access plan:",
Markup.inlineKeyboard([
[
Markup.button.callback(
"7 days - $15",
"plan:weekly",
),
],
[
Markup.button.callback(
"30 days - $39",
"plan:monthly",
),
],
[
Markup.button.callback(
"90 days - $99",
"plan:quarterly",
),
],
]),
);
});
bot.action(/^plan:(.+)$/, async (ctx) => {
await ctx.answerCbQuery();
const planCode = ctx.match[1];
const telegramMember =
await upsertTelegramMember(ctx.from);
const community =
await getDefaultCommunity();
const plan = await getPlan({
communityId: community.id,
code: planCode,
});
const merchant = await getMerchant(
community.merchantId,
);
const subscription =
await getSubscription({
communityId: community.id,
telegramMemberId: telegramMember.id,
});
const purpose =
subscription?.status === "active"
? "renewal"
: "new_membership";
const checkout =
await createTelegramCheckout({
merchant,
community,
telegramMember,
plan,
purpose,
});
await ctx.reply(
[
`Plan: ${plan.name}`,
`Price: ${plan.price} ${plan.currency}`,
"",
"Complete the payment using this link:",
checkout.paymentUrl,
"",
"Access will be prepared after the payment reaches its final paid status.",
"Do not send another payment while your transaction is being processed.",
].join("\n"),
);
});
The bot stores the Telegram user before invoice creation.
The payment is therefore connected to a stable Telegram identity from the beginning.
Identify the merchant before validating OxaPay
A multi-merchant system needs the correct Merchant API Key to validate the callback.
Do not use fields from an unverified payload to select that key.
Give each merchant a public endpoint identifier:
/webhooks/oxapay/{endpoint_id}
The endpoint ID identifies the merchant configuration.
It must not provide dashboard or API access.
Validate the OxaPay webhook
OxaPay signs the raw request body using HMAC SHA-512 and the Merchant API Key.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/oxapay/:endpointId",
express.raw({ type: "application/json" }),
async (req, res) => {
const merchant = await db.merchant.findUnique({
where: {
webhookEndpointId:
req.params.endpointId,
},
});
if (!merchant) {
return res.status(404).send("unknown endpoint");
}
const rawBody = req.body;
const receivedHmac = req.get("HMAC");
const merchantApiKey = await decryptSecret(
merchant.oxapayMerchantApiKeyEncrypted,
);
const expectedHmac = crypto
.createHmac("sha512", merchantApiKey)
.update(rawBody)
.digest("hex");
if (
!safeEqualSha512(
receivedHmac,
expectedHmac,
)
) {
await recordRejectedPaymentWebhook({
merchantId: merchant.id,
reason: "invalid_hmac",
});
return res
.status(401)
.send("invalid signature");
}
let payload;
try {
payload = JSON.parse(
rawBody.toString("utf8"),
);
} catch {
return res.status(400).send("invalid json");
}
const payloadHash = crypto
.createHash("sha256")
.update(rawBody)
.digest("hex");
try {
await persistPaymentEventAndOutbox({
merchant,
payload,
payloadHash,
});
return res.status(200).send("ok");
} catch (error) {
console.error(
"Payment webhook persistence failed",
error,
);
return res.status(500).send("failed");
}
},
);
function safeEqualSha512(
received,
expected,
) {
const sha512Hex = /^[a-f0-9]{128}$/i;
if (
!received ||
!expected ||
!sha512Hex.test(received) ||
!sha512Hex.test(expected)
) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(received, "hex"),
Buffer.from(expected, "hex"),
);
}
The event and outbox job should be inserted in one database transaction.
This prevents the event from being stored while the membership-processing job is lost.
Normalize payment status
const PAYMENT_STATUS_MAP = {
new: "created",
waiting: "waiting",
paying: "confirming",
paid: "paid",
manual_accept: "manually_accepted",
underpaid: "underpaid",
expired: "expired",
refunding: "refund_in_progress",
refunded: "refunded",
};
function normalizeProviderStatus(status) {
return String(status ?? "")
.trim()
.toLowerCase();
}
function mapPaymentStatus(status) {
return (
PAYMENT_STATUS_MAP[
normalizeProviderStatus(status)
] ?? "unknown"
);
}
Do not grant access on paying.
The bot may tell the customer:
Payment activity detected. Waiting for completion.
Membership activation should normally begin after paid.
A manually accepted payment should follow a separate merchant policy and preserve an audit record.
Verify the latest payment state
Before creating a membership term, retrieve Payment Information using the track_id.
async function fetchOxaPayPayment({
merchantApiKey,
trackId,
}) {
const response = await fetch(
`${OXAPAY_API}/payment/${encodeURIComponent(trackId)}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
merchant_api_key: merchantApiKey,
},
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`Payment lookup failed with ${response.status}`,
);
}
return payload.data;
}
function assertPaymentMatchesCheckout({
payment,
checkout,
}) {
if (
normalizeProviderStatus(payment.status) !==
"paid"
) {
throw new PermanentMembershipError(
"Payment is not in the paid state",
);
}
if (
String(payment.order_id) !==
String(checkout.providerOrderId)
) {
throw new PermanentMembershipError(
"Payment order_id does not match checkout",
);
}
if (
decimal(payment.amount).lessThan(
decimal(checkout.amount),
)
) {
throw new PermanentMembershipError(
"Paid amount does not satisfy checkout",
);
}
}
Use decimal arithmetic for financial values.
Do not use ordinary floating-point comparisons for payment decisions.
Create one membership term per payment
import { addDays } from "date-fns";
export async function applyPaidCheckout(
checkoutId,
) {
return db.$transaction(async (tx) => {
const checkout =
await tx.checkoutSession.findUnique({
where: { id: checkoutId },
});
if (!checkout) {
throw new PermanentMembershipError(
"Checkout session not found",
);
}
const existingTerm =
await tx.membershipTerm.findUnique({
where: {
checkoutSessionId: checkout.id,
},
});
if (existingTerm) {
return existingTerm;
}
const plan = await tx.accessPlan.findUnique({
where: { id: checkout.planId },
});
if (!plan || !plan.active) {
throw new PermanentMembershipError(
"Access plan is unavailable",
);
}
const subscription =
await tx.subscription.findUnique({
where: {
id: checkout.subscriptionId,
},
});
if (!subscription) {
throw new PermanentMembershipError(
"Subscription not found",
);
}
const now = new Date();
const startsAt =
subscription.currentTermEnd &&
subscription.currentTermEnd > now
? subscription.currentTermEnd
: now;
const endsAt = addDays(
startsAt,
plan.durationDays,
);
const term =
await tx.membershipTerm.create({
data: {
subscriptionId: subscription.id,
checkoutSessionId: checkout.id,
planId: plan.id,
startsAt,
endsAt,
grantType: checkout.purpose,
grantedBy: "verified_payment",
},
});
await tx.subscription.update({
where: { id: subscription.id },
data: {
currentPlanId: plan.id,
status: "active",
currentTermStart: startsAt,
currentTermEnd: endsAt,
graceUntil: null,
},
});
await tx.checkoutSession.update({
where: { id: checkout.id },
data: {
providerStatus: "paid",
internalStatus: "applied",
paidAt: now,
},
});
await tx.outboxJob.create({
data: {
topic: "telegram.prepare_access",
payload: {
subscriptionId: subscription.id,
membershipTermId: term.id,
},
},
});
return term;
});
}
The unique checkout constraint makes this operation idempotent.
The same payment cannot create two terms.
Prefer join-request links for paid access
Create a link requiring administrator approval.
async function telegramApi(
botToken,
method,
body,
) {
const response = await fetch(
`https://api.telegram.org/bot${botToken}/${method}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
const payload = await response.json();
if (!payload.ok) {
const error = new Error(
payload.description ??
`Telegram ${method} failed`,
);
error.errorCode = payload.error_code;
throw error;
}
return payload.result;
}
async function createJoinRequestLink({
botToken,
chatId,
name,
expiresAt,
}) {
return telegramApi(
botToken,
"createChatInviteLink",
{
chat_id: chatId,
name: name.slice(0, 32),
expire_date: Math.floor(
expiresAt.getTime() / 1000,
),
creates_join_request: true,
},
);
}
Do not add member_limit when creates_join_request is true.
Prepare Telegram access idempotently
export async function prepareTelegramAccess({
subscriptionId,
membershipTermId,
}) {
const subscription =
await db.subscription.findUnique({
where: { id: subscriptionId },
include: {
telegramMember: true,
community: {
include: {
merchant: true,
},
},
},
});
const term =
await db.membershipTerm.findUnique({
where: { id: membershipTermId },
});
if (!subscription || !term) {
throw new PermanentAccessError(
"Membership data is incomplete",
);
}
const idempotencyKey = [
subscription.communityId,
term.id,
"create_join_request_link",
].join(":");
const existingAction =
await db.telegramAccessAction.findUnique({
where: { idempotencyKey },
});
if (
existingAction?.status === "completed"
) {
return existingAction.resultData;
}
const action =
existingAction ??
(await db.telegramAccessAction.create({
data: {
communityId:
subscription.communityId,
subscriptionId: subscription.id,
actionType:
"create_join_request_link",
idempotencyKey,
status: "running",
attemptCount: 1,
},
}));
const botToken = await decryptSecret(
subscription.community.merchant
.telegramBotTokenEncrypted,
);
const expiresAt = new Date(
Date.now() + 15 * 60 * 1000,
);
try {
const invite =
await createJoinRequestLink({
botToken,
chatId:
subscription.community
.telegramChatId,
name: `term_${term.id}`,
expiresAt,
});
const inviteRecord =
await db.telegramInvite.create({
data: {
communityId:
subscription.communityId,
subscriptionId:
subscription.id,
membershipTermId: term.id,
expectedTelegramUserId:
subscription.telegramMember
.telegramUserId,
inviteLink: invite.invite_link,
accessMode: "join_request",
status: "sent",
expiresAt,
},
});
await telegramApi(
botToken,
"sendMessage",
{
chat_id:
subscription.telegramMember
.telegramUserId,
text: [
"Your payment has been confirmed.",
"",
"Use this link to request access:",
invite.invite_link,
"",
"The request must come from the same Telegram account that purchased the plan.",
"This link expires in 15 minutes.",
].join("\n"),
},
);
await db.telegramAccessAction.update({
where: { id: action.id },
data: {
status: "completed",
resultData: {
inviteId: inviteRecord.id,
},
completedAt: new Date(),
},
});
return inviteRecord;
} catch (error) {
await recordTelegramActionFailure({
actionId: action.id,
error,
});
throw error;
}
}
If the member was previously banned because an earlier term expired, unban them before sending the new link.
Verify Telegram webhook requests
Telegram's setWebhook method supports a secret_token.
Telegram then sends that value in:
X-Telegram-Bot-Api-Secret-Token
Use it to reject requests that were not sent through the webhook configuration you created.
app.post(
"/webhooks/telegram/:botEndpointId",
express.json(),
async (req, res) => {
const botConfig =
await loadBotConfiguration(
req.params.botEndpointId,
);
if (!botConfig) {
return res.status(404).send("unknown bot");
}
const receivedSecret = req.get(
"X-Telegram-Bot-Api-Secret-Token",
);
if (
receivedSecret !==
botConfig.webhookSecret
) {
return res
.status(401)
.send("invalid secret");
}
await storeTelegramUpdateOnce({
botConfig,
update: req.body,
});
return res.status(200).send("ok");
},
);
Telegram updates include an update_id.
Use it to deduplicate update processing.
Subscribe to the required Telegram updates
When configuring the Telegram webhook, include the updates needed for access control.
await telegramApi(
botToken,
"setWebhook",
{
url:
`${process.env.APP_URL}/webhooks/telegram/${botEndpointId}`,
secret_token: telegramWebhookSecret,
allowed_updates: [
"message",
"callback_query",
"chat_join_request",
"chat_member",
"my_chat_member",
],
},
);
chat_member is not included automatically in the normal default set.
Request it explicitly when you need member-status tracking.
Approve only the expected user
export async function handleJoinRequest({
botToken,
update,
}) {
const request = update.chat_join_request;
if (!request) {
return;
}
const inviteLink =
request.invite_link?.invite_link;
const telegramUserId =
request.from.id;
if (!inviteLink) {
await createAccessCase({
caseType: "join_request_without_link",
severity: "medium",
summary:
"A Telegram join request has no traceable invite link.",
});
return;
}
const invite =
await db.telegramInvite.findUnique({
where: { inviteLink },
include: {
subscription: true,
membershipTerm: true,
community: true,
},
});
if (!invite) {
await declineJoinRequest({
botToken,
chatId: request.chat.id,
telegramUserId,
});
return;
}
const now = new Date();
const identityMatches =
String(
invite.expectedTelegramUserId,
) === String(telegramUserId);
const accessIsActive =
invite.subscription.status ===
"active" &&
invite.membershipTerm.startsAt <=
now &&
invite.membershipTerm.endsAt >
now;
const inviteIsValid =
invite.status === "sent" &&
invite.expiresAt > now;
if (
!identityMatches ||
!accessIsActive ||
!inviteIsValid
) {
await declineJoinRequest({
botToken,
chatId: request.chat.id,
telegramUserId,
});
await createAccessCase({
communityId: invite.communityId,
subscriptionId:
invite.subscriptionId,
caseType:
identityMatches
? "invalid_membership_join_request"
: "unexpected_user_join_request",
severity: "high",
summary:
"A Telegram join request did not satisfy the access policy.",
});
return;
}
await telegramApi(
botToken,
"approveChatJoinRequest",
{
chat_id: request.chat.id,
user_id: telegramUserId,
},
);
await db.telegramInvite.update({
where: { id: invite.id },
data: {
status: "approved",
joinedAt: now,
},
});
}
async function declineJoinRequest({
botToken,
chatId,
telegramUserId,
}) {
return telegramApi(
botToken,
"declineChatJoinRequest",
{
chat_id: chatId,
user_id: telegramUserId,
},
);
}
A forwarded link now has limited value.
The wrong user can request access, but the backend declines the request because the Telegram ID does not match the paid checkout.
Track the final membership result
Approval does not always mean the complete lifecycle is finished.
Use chat_member updates to record when the user becomes a member or leaves.
export async function handleChatMemberUpdate(
update,
) {
const memberUpdate = update.chat_member;
if (!memberUpdate) {
return;
}
const telegramUserId =
memberUpdate.new_chat_member.user.id;
const chatId = memberUpdate.chat.id;
const oldStatus =
memberUpdate.old_chat_member.status;
const newStatus =
memberUpdate.new_chat_member.status;
await recordMembershipStatusChange({
chatId,
telegramUserId,
oldStatus,
newStatus,
inviteLink:
memberUpdate.invite_link
?.invite_link ?? null,
occurredAt: new Date(
memberUpdate.date * 1000,
),
});
}
The admin timeline can now distinguish:
Payment confirmed
Invite sent
Join requested
Join approved
User became member
Renewal should create a new payment
Crypto membership renewal is usually invoice-based.
A renewal worker can identify expiring terms:
Five days before expiry
-> Offer renewal checkout
Three days before expiry
-> Send reminder
One day before expiry
-> Send final reminder
At term end
-> Enter grace period or expire
After grace
-> Revoke Telegram access
The renewal payment creates a new membership term.
It does not modify the old payment record.
Do not send repeated renewal invoices
Only one open renewal checkout should exist for the same subscription and renewal window.
async function prepareRenewal(
subscription,
) {
const existingCheckout =
await db.checkoutSession.findFirst({
where: {
subscriptionId: subscription.id,
purpose: "renewal",
internalStatus: {
in: [
"created",
"invoice_created",
"waiting",
"confirming",
],
},
},
});
if (existingCheckout) {
return existingCheckout;
}
return createRenewalCheckout(
subscription,
);
}
A scheduled task should be safe to run repeatedly.
Model grace periods explicitly
A grace period is a merchant policy, not a paid term.
During grace, the merchant may choose to:
- leave access active temporarily
- restrict posting but allow reading
- send additional reminders
- revoke access immediately for high-value communities
Make the policy explicit.
Do not silently extend current_term_end to simulate grace.
Use a separate grace_until field.
Revoke expired access
Telegram's banChatMember can remove a member and prevent them from returning through an invite link until they are unbanned.
For a paid-access system, that creates a clear lifecycle:
Term expires
-> Ban member
-> Mark access removed
Renewal later succeeds
-> Unban member
-> Create new join-request link
export async function revokeTelegramAccess({
subscription,
community,
telegramMember,
botToken,
}) {
const idempotencyKey = [
community.id,
subscription.id,
subscription.currentTermEnd
?.toISOString(),
"revoke_access",
].join(":");
const action =
await db.telegramAccessAction.upsert({
where: { idempotencyKey },
create: {
communityId: community.id,
subscriptionId:
subscription.id,
actionType: "ban_expired_member",
idempotencyKey,
status: "running",
attemptCount: 1,
},
update: {},
});
if (action.status === "completed") {
return;
}
try {
await telegramApi(
botToken,
"banChatMember",
{
chat_id:
community.telegramChatId,
user_id:
telegramMember.telegramUserId,
revoke_messages: false,
},
);
await db.$transaction([
db.subscription.update({
where: {
id: subscription.id,
},
data: {
status: "expired",
},
}),
db.telegramAccessAction.update({
where: { id: action.id },
data: {
status: "completed",
completedAt: new Date(),
},
}),
]);
} catch (error) {
await recordTelegramActionFailure({
actionId: action.id,
error,
});
await createAccessCase({
communityId: community.id,
subscriptionId:
subscription.id,
caseType:
"expired_member_removal_failed",
severity: "high",
summary:
"The membership expired, but Telegram access could not be removed.",
recommendedAction:
"Verify bot permissions and retry member removal.",
});
throw error;
}
}
The bot needs the relevant administrator permissions.
Do not promise automatic removal before testing the exact channel or supergroup configuration.
Check bot permissions during onboarding
A merchant setup flow should verify:
- the chat exists
- the bot is an administrator
- the bot can invite users
- the bot can restrict or ban members
- the stored chat ID is correct
- the bot can create invite links
- the Telegram webhook is configured
- required update types are enabled
Build a setup checker.
Do not wait until the first paid user discovers a missing permission.
Handle failures by layer
Payment paid, term not created
Create:
paid_not_applied
Recommended action:
Verify Payment Information and retry the membership worker.
Term active, invite not sent
Create:
access_delivery_failed
Recommended action:
Check bot availability, private-message capability, and Telegram API response.
Wrong user requests access
Create:
unexpected_user_join_request
Recommended action:
Decline automatically and preserve the event.
Invite expires unused
Create:
invite_expired_unused
Recommended action:
Allow the paid user to request a replacement link after identity verification.
Expired member remains inside
Create:
revocation_failed
Recommended action:
Check administrator rights and retry the removal.
Provider paid, local payment missing
Create:
provider_payment_unmatched
Recommended action:
Search by track_id, order_id, Telegram user, amount, and payment time.
Recover missed payment events
Webhooks provide the real-time path.
Use OxaPay Payment Information to refresh one payment.
Use Payment History for scheduled recovery.
A practical schedule:
Every 10 minutes:
- query an overlapping recent payment window
- upsert provider payment records by track_id
- identify paid payments not applied locally
- create recovery events
- detect expired local checkouts
Every hour:
- find active terms without confirmed Telegram membership
- retry eligible access delivery
- surface unresolved join requests
Every night:
- compare active terms with Telegram member state
- compare expired terms with removal actions
- send an unresolved-case report
Recovered payment events should be labeled:
source = payment_history_backfill
Do not present them as original webhook deliveries.
Give members a self-service status command
A /status command can reduce support tickets.
Plan: Monthly Access
Payment: Confirmed
Membership: Active
Access: Joined
Expires: September 15
Renewal: Available
For a pending payment:
Payment activity detected.
Do not send another payment while it is processing.
For paid but undelivered access:
Your payment is confirmed, but access delivery needs attention.
You do not need to pay again.
For an expired membership:
Your access term has ended.
Use /renew to create a new payment request.
Do not expose raw provider payloads or internal errors to members.
Build the admin workspace around exceptions
The merchant dashboard should answer:
Who paid?
Who currently has access?
Which paid users did not receive access?
Which memberships expire soon?
Which expired users remain in the community?
Which join requests were rejected?
Which renewals are pending?
Useful views include:
Members
- Telegram ID
- username
- community
- plan
- subscription status
- term end
- Telegram member state
- next reminder
Payments
track_id- order ID
- expected amount
- provider status
- local status
- applied membership term
- payment source
Access timeline
10:01 Plan selected
10:01 Invoice created
10:05 Payment activity detected
10:06 Payment confirmed
10:06 Membership term created
10:06 Join-request link sent
10:08 Join requested
10:08 Request approved
10:09 User became member
Needs attention
- paid but access not delivered
- invitation expired
- unexpected user attempted entry
- active subscription but user is absent
- expired subscription but user remains inside
- invalid OxaPay webhook
- failed Telegram action
- unmatched provider payment
Safe admin actions
- refresh payment
- resend access link
- create replacement invite
- retry access removal
- send renewal reminder
- grant authorized manual term
- revoke access
- add internal note
- assign operational case
Every manual change should identify:
Actor
Time
Reason
Previous state
New state
Low-code can validate demand
OxaPay and Telegram can also be connected through Make or n8n.
A low-code prototype may implement:
Telegram plan selection
-> OxaPay invoice
-> Payment webhook
-> Telegram notification
-> Spreadsheet membership record
This can work for a low-volume first client.
It is useful for:
- validating merchant demand
- demonstrating the workflow
- creating a managed service
- discovering common plans and support cases
A custom backend becomes more valuable when you need:
- strict identity matching
- join-request approval
- membership-term idempotency
- reliable expiry workers
- multi-merchant isolation
- audit history
- case management
- recovery from missed events
- role-based administration
Low-code orchestration does not remove the need for payment and access controls.
The MVP
The first version should support one complete lifecycle:
One Telegram community
One merchant
Several access plans
OxaPay hosted invoice
Verified paid callback
One membership term
Join-request approval
Renewal reminder
Expiry and removal
Admin search
Build:
/start- plan selection
- Telegram user storage
- local checkout creation
- OxaPay invoice generation
- HMAC-validated payment webhook
- payment event and outbox storage
- Payment Information verification
- idempotent membership terms
- join-request invite links
- Telegram join approval
-
chat_membertracking - renewal reminders
- expiry worker
- access revocation
/status- basic needs-attention dashboard
- Payment History recovery
Do not include in version one:
- affiliate payouts
- public community marketplace
- ten different community types
- advanced analytics
- AI support decisions
- complex coupons
- several payment providers
- native mobile application
- full CRM replacement
Reliability is the wedge.
Production safeguards
Before selling the system, implement:
- encrypted OxaPay Merchant API Key storage
- encrypted Telegram bot token storage
- HMAC verification over the raw payment body
- Telegram webhook
secret_token - event deduplication
- membership-term uniqueness
- asynchronous Telegram actions
- action-level idempotency
- retries with backoff
- permanent versus transient error classification
- explicit bot permission checks
- join identity validation
- access-removal verification
- Payment History recovery
- tenant-isolated queries
- role-based admin permissions
- manual-action audit logs
- secret masking
- rate limiting
- incident alerts
Test at least:
Valid paid payment creates one term
Duplicate paid callbacks do not extend access twice
Confirming payment does not grant access
Forwarded join-request link is declined for the wrong Telegram user
Expected paid user is approved
Expired invite can be replaced safely
Renewal extends from the existing term end
Expired renewal invoice does not shorten current access
Expiry worker removes access
Failed removal creates a visible case
Paid payment with failed invite delivery remains visible
Payment History recovers a missed paid event
Refunded payment creates an operational review
Product positioning
Weak positioning:
I build Telegram bots with crypto payments.
Better positioning:
I build crypto-paid Telegram membership systems.
Stronger positioning:
Sell access to private Telegram communities with verified crypto invoices, identity-checked join requests, renewals, expiry automation, and a searchable admin timeline.
A niche-specific version is stronger:
Crypto membership infrastructure for Telegram course communities, including monthly plans, automatic access approval, renewal reminders, expiry handling, and support-ready payment records.
The merchant is not buying a bot.
They are buying controlled access operations.
What makes this a real product?
A basic bot says:
If payment is paid, send invite link.
A production paid-access system says:
Capture Telegram identity
-> Create authoritative checkout
-> Generate crypto invoice
-> Validate signed payment event
-> Verify provider state
-> Create one membership term
-> Create identity-controlled join request
-> Approve only the expected Telegram user
-> Track actual member state
-> Remind before term expiry
-> Revoke access after expiry
-> Recover missed payment events
-> Surface every operational failure
That is the difference between a bot integration and paid membership infrastructure.
Final takeaway
A Telegram Paid Access System is not simply a payment bot.
It is a membership-control system.
OxaPay provides the payment primitives:
- hosted invoices
- unique
track_idvalues - Merchant
order_idreferences - HMAC-signed webhooks
- Payment Information
- Payment History
- white-label payments
- automation integrations
Telegram provides the access primitives:
- additional invite links
- join-request links
- join approval and decline methods
- member-status updates
- invite revocation
- member restriction and removal
- webhook authentication tokens
The developer builds the control layer between them.
Start with one community.
Attach every payment to one Telegram identity.
Grant every membership term exactly once.
Approve only the expected member.
Make expiry enforceable.
Make every failure visible.
That is how a Telegram bot becomes reliable paid-access infrastructure.
Would you start with a course community, a creator membership, a premium research channel, or a private software-support group?
Related articles
- 10 Crypto Payment Products Developers Can Build for Merchants
- Build a Crypto PaymentOps Service for Merchants
- Build a Payment Automation Studio for Crypto Merchants
- Build a Crypto Payment Module for SaaS Apps
- Build a Crypto Payment Reconciliation Tool for Merchants
- Build a Crypto Payment Support Desk
References
OxaPay
- OxaPay Generate Invoice
- OxaPay Generate White Label
- OxaPay Payment Information
- OxaPay Payment History
- OxaPay Payment Status Table
- OxaPay Webhook
- OxaPay Python SDK
- OxaPay Telegram Bot with Make
Top comments (0)