DEV Community

kevin.s
kevin.s

Posted on • Edited on

Build a Telegram Paid Access System with Crypto Payments

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A real system needs separate states.

Payment state

created
waiting
confirming
paid
underpaid
expired
refunded
review_required
Enter fullscreen mode Exit fullscreen mode

Subscription state

pending
active
grace_period
expired
revoked
canceled
Enter fullscreen mode Exit fullscreen mode

Telegram access state

not_requested
invite_pending
join_requested
approved
member
removal_pending
removed
failed
Enter fullscreen mode Exit fullscreen mode

These combinations are valid:

Payment: paid
Subscription: active
Telegram access: failed
Enter fullscreen mode Exit fullscreen mode

The customer paid, but the system still owes access.

Another valid state:

Payment: expired
Subscription: active
Telegram access: member
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Payment processing and Telegram actions should not run directly inside the OxaPay webhook request.

The webhook endpoint should:

  1. Identify the merchant.
  2. Preserve the raw request body.
  3. validate the HMAC signature.
  4. Store the payment event.
  5. Create an outbox job.
  6. Return HTTP 200 with ok.

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
);
Enter fullscreen mode Exit fullscreen mode

The key constraint is:

UNIQUE (checkout_session_id)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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"),
  );
});
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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"),
  );
}
Enter fullscreen mode Exit fullscreen mode

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"
  );
}
Enter fullscreen mode Exit fullscreen mode

Do not grant access on paying.

The bot may tell the customer:

Payment activity detected. Waiting for completion.
Enter fullscreen mode Exit fullscreen mode

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",
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
  },
);
Enter fullscreen mode Exit fullscreen mode

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",
    ],
  },
);
Enter fullscreen mode Exit fullscreen mode

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,
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
    ),
  });
}
Enter fullscreen mode Exit fullscreen mode

The admin timeline can now distinguish:

Payment confirmed
Invite sent
Join requested
Join approved
User became member
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Recommended action:

Verify Payment Information and retry the membership worker.

Term active, invite not sent

Create:

access_delivery_failed
Enter fullscreen mode Exit fullscreen mode

Recommended action:

Check bot availability, private-message capability, and Telegram API response.

Wrong user requests access

Create:

unexpected_user_join_request
Enter fullscreen mode Exit fullscreen mode

Recommended action:

Decline automatically and preserve the event.

Invite expires unused

Create:

invite_expired_unused
Enter fullscreen mode Exit fullscreen mode

Recommended action:

Allow the paid user to request a replacement link after identity verification.

Expired member remains inside

Create:

revocation_failed
Enter fullscreen mode Exit fullscreen mode

Recommended action:

Check administrator rights and retry the removal.

Provider paid, local payment missing

Create:

provider_payment_unmatched
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Recovered payment events should be labeled:

source = payment_history_backfill
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For a pending payment:

Payment activity detected.
Do not send another payment while it is processing.
Enter fullscreen mode Exit fullscreen mode

For paid but undelivered access:

Your payment is confirmed, but access delivery needs attention.
You do not need to pay again.
Enter fullscreen mode Exit fullscreen mode

For an expired membership:

Your access term has ended.
Use /renew to create a new payment request.
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_member tracking
  • 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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_id values
  • Merchant order_id references
  • 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

References

OxaPay

Telegram

Top comments (0)