New Safe Mode warms up new numbers to cut the risk of a WhatsApp ban. Learn more →

Maytapi/Blog/Integrations

Paid WhatsApp Group with Stripe: Auto Add and Remove Members

Sync a paid WhatsApp group with Stripe subscriptions: add buyers on checkout, send the invite link if adding fails, and remove them when the subscription ends.

Integrations Updated Oct 27, 2026·8 min read

Paid WhatsApp Group with Stripe: Auto Add and Remove Members

A paid WhatsApp group sounds simple: someone pays, they get in, and when they stop paying, they're out. Done by hand, it turns into a spreadsheet of numbers, a backlog of "I paid but I'm not in the group yet" messages, and former members who stay in for months because nobody remembered to remove them. This guide connects Stripe to a WhatsApp group with the Maytapi API so membership follows the subscription: the buyer is added when checkout completes, gets the invite link if WhatsApp won't let you add them, and is removed when the subscription ends.

How the pieces fit together

There is no Maytapi app to install in Stripe. The integration is two webhooks and a small table you own:

  1. The customer pays on Stripe Checkout. Checkout collects their phone number along with the payment.
  2. Stripe calls your server with checkout.session.completed. You verify the signature, save the phone number next to the subscription ID, and call POST /group/add.
  3. If WhatsApp refuses the add because of the person's privacy settings, you send them the group's invite link in a private message.
  4. When the subscription ends, Stripe sends customer.subscription.deleted. You look up the phone number and call POST /group/remove.
Flow of a paid WhatsApp group: Stripe checkout completes, server verifies the webhook and stores the phone, Maytapi group/add adds the buyer or the invite link is sent, subscription deleted triggers group/remove

All Maytapi calls use the base URL https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/ and your token in the x-maytapi-key header. The connected number must be an admin of the group, since only admins can add and remove members.

Step Stripe event Maytapi endpoint
Grant access checkout.session.completed POST /group/add
Check membership GET /getGroups/{conversation_id}
Get the invite link GET /getGroups/{conversation_id}?invite=true
Send the link privately POST /sendMessage
Revoke access customer.subscription.deleted POST /group/remove
Reset a leaked link POST /group/revokeInvite

Prepare the group and Stripe Checkout

Create the group once (or use an existing one) and note its ID, which ends in @g.us. The group API guide shows how to create it and find IDs with getGroups. Then lock it down so only admins can add people:

Terminal
curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \
  -H "x-maytapi-key: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conversation_id": "120363012345678901@g.us",
    "config": { "membersCanAddMembers": false }
  }'

On the Stripe side, create the Checkout Session in subscription mode and turn on phone number collection, so the number arrives in the webhook as customer_details.phone:

JavaScript
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: "price_123", quantity: 1 }],
  phone_number_collection: { enabled: true },
  success_url: "https://example.com/welcome",
  cancel_url: "https://example.com/pricing"
});

Stripe returns the number in international format, for example +1415xxxxx67. Maytapi wants digits only, country code first, so strip everything that isn't a digit before you store it.

Store the subscription to phone mapping

Every later decision depends on knowing which WhatsApp number belongs to which subscription. Keep one row per subscription:

Column Example Why
subscription_id sub_1Q... Key for the cancellation event
customer_id cus_Q... Find the member from Stripe's dashboard
phone 1415xxxxx67 Digits only, used for every Maytapi call
group_id 120363012345678901@g.us Lets one server run several paid groups
status active, invited, removed What your app last did

Add a second table of processed Stripe event IDs. Stripe can deliver the same event more than once, and it retries any delivery your endpoint didn't acknowledge. Storing event.id after a successful run lets you skip duplicates without thinking about them.

Add the buyer when checkout completes

Here is the whole receiver in Node.js with Express and the official stripe package. The Stripe route must read the raw request body, because the signature check runs on the exact bytes Stripe sent.

JavaScript
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env;

const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`;
const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" };
const GROUP_ID = "120363012345678901@g.us";

const app = express();

app.post("/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], endpointSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (await db.eventSeen(event.id)) return res.sendStatus(200);
  try {
    await handle(event);
    await db.markEventSeen(event.id);
    res.sendStatus(200);
  } catch (err) {
    console.error(event.type, err.message);
    res.sendStatus(500); // Stripe will retry
  }
});

async function handle(event) {
  const obj = event.data.object;

  if (event.type === "checkout.session.completed") {
    if (obj.mode !== "subscription") return;
    const phone = (obj.customer_details?.phone || "").replace(/\D/g, "");
    if (!phone) return db.flagForManualReview(obj.id);

    await db.upsertMember({ subscription_id: obj.subscription, customer_id: obj.customer, phone, group_id: GROUP_ID });
    const result = await grantAccess(phone);
    await db.setStatus(obj.subscription, result === "invited" ? "invited" : "active");
  }

  if (event.type === "customer.subscription.deleted") {
    const member = await db.findBySubscription(obj.id);
    if (!member || member.status === "removed") return;
    await revokeAccess(member.phone);
    await db.setStatus(obj.id, "removed");
  }
}

app.listen(3000);

db stands for your own storage. Keep express.json() off this route, or apply it only to your other routes, otherwise the body is parsed before Stripe's check sees it and every signature fails.

What group/add tells you

The add call takes the group ID and the buyer's number. Add one buyer per call, which makes the response easy to read:

JavaScript
async function maytapi(path, body) {
  const opts = body ? { method: "POST", headers, body: JSON.stringify(body) } : { headers };
  const r = await fetch(`${BASE}/${path}`, opts);
  return r.json();
}

async function isMember(phone) {
  const res = await maytapi(`getGroups/${GROUP_ID}`);
  return !!res.success && (res.data.participants || []).includes(`${phone}@c.us`);
}

async function grantAccess(phone) {
  if (await isMember(phone)) return "already";

  const res = await maytapi("group/add", {
    conversation_id: GROUP_ID,
    numbers: [phone],
    sendInvite: true,
    message: "Welcome to Pro Members! Tap to join the group."
  });

  if (!res.success) {
    if (/ALREADY_EXISTS/.test(res.message || "")) return "already";
    throw new Error(`group/add failed: ${res.message}`);
  }

  if (res.data && (res.data.cannotAdd || res.data.inviteLink)) {
    await sendInviteLink(phone, res.data.inviteLink);
    return "invited";
  }
  return "added";
}

There are three outcomes to handle:

  • Added. success is true and data has neither cannotAdd nor inviteLink. The person is in the group. Depending on the connection, data is true or the updated group object, so don't rely on its exact shape.
  • Not added. success is still true, but data includes inviteLink, and usually a cannotAdd object whose cannotAdded array lists the numbers that failed. This is what happens when someone's privacy settings only allow contacts to add them, or when the number isn't on WhatsApp. With sendInvite: true, people blocked by privacy also receive WhatsApp's own invitation card with your message.
  • Call failed. success is false and message explains why: the group wasn't found, your number isn't an admin, or the Safe Mode tier blocked the change. On some connections, adding someone who is already a member also comes back as success: false with an "already exists" message, which is why the code checks membership first and treats that message as done.

Pass sendInvite: true explicitly. Don't rely on a default.

WhatsApp's invitation card is easy to miss, so follow it with a plain message that has the link. The add response usually carries inviteLink already. If it doesn't, read it from the group:

JavaScript
async function sendInviteLink(phone, link) {
  if (!link) {
    const res = await maytapi(`getGroups/${GROUP_ID}?invite=true`);
    link = res.success && res.data.invite;
  }
  if (!link) throw new Error("No invite link available");

  await maytapi("sendMessage", {
    to_number: phone,
    type: "text",
    message: `Thanks for subscribing! Join the members group here: ${link}`
  });
}

invite holds the https://chat.whatsapp.com/... link, or false when your number can't get one (usually because it isn't an admin). The send message guide covers the rest of sendMessage.

If you want to catch typos before paying customers wonder where their invite went, check the number first with the bulk number check endpoints. A number that isn't on WhatsApp should go to manual review, not to a retry loop.

Remove the member when the subscription ends

Stripe sends customer.subscription.deleted when a subscription actually ends: immediately for an instant cancellation, or at the end of the paid period if the customer cancelled with cancel_at_period_end. That's the right moment to take access away. group/remove takes one participant in number, in chat ID form:

JavaScript
async function revokeAccess(phone) {
  const res = await maytapi("group/remove", {
    conversation_id: GROUP_ID,
    number: `${phone}@c.us`
  });
  if (!res.success && (await isMember(phone))) {
    throw new Error(`group/remove failed: ${res.message}`);
  }
}

The response is just success. It's false when the person is no longer a participant (they left on their own) as well as when the call genuinely failed, so the code checks membership before deciding it's an error. A member who already left counts as removed.

Don't confuse checkout.session.expired with a membership expiring. That event means a Checkout page was abandoned before payment. For fixed-length passes sold as one-time payments, store an end date in your table and run a daily job that calls the same revokeAccess for rows past it.

Failed renewals go through Stripe's retry settings. Depending on your configuration, the subscription moves to past_due or unpaid (reported in customer.subscription.updated) before it's cancelled. Whether you remove people at past_due or wait for deleted is a business decision. Waiting is friendlier, and the code above already handles it.

Optional: approval mode instead of direct adds

Direct adds keep the experience instant, but they can fail on privacy settings, and an invite link sent to one buyer can be forwarded to anyone. Approval mode closes that gap. Turn on approveNewMembers, show the invite link on your welcome page, and anyone who opens it becomes a pending request instead of a member:

Terminal
curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \
  -H "x-maytapi-key: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conversation_id": "120363012345678901@g.us",
    "config": { "approveNewMembers": true }
  }'

A small job then lists requests with GET /group/approvalRequestList/{conversation_id} every minute or two, approves the numbers that have an active row in your table with POST /group/approvalRequest/Approve, and leaves or rejects the rest. WhatsApp doesn't send a webhook for new requests, so polling is how this works. The group join requests guide has the full loop. Approval mode and direct adds also work together: admins can still add buyers directly, and the link only needs approval for everyone else.

If a link leaks without approval mode on, reset it with POST /group/revokeInvite. The old link stops working, and the response returns the new one as data.inviteLink.

Make it safe to run twice

Webhooks arrive late, twice, or not in the order you expect. A few habits keep the group correct anyway:

  • Skip processed events. Check event.id before doing anything, and mark it only after the work succeeded, so a failed run is retried.
  • Check membership before adding. getGroups/{conversation_id} returns participants as @c.us IDs. Some participants who hide their number show up as @lid IDs instead, and isMember above does not match those, so a member with a LID looks like a non-member and the add runs again. The "already exists" branch catches that case; to avoid the extra call, resolve LIDs first as the LID to phone number guide explains. Use ?refresh=true when the list looks stale, not on every call.
  • Let your table win. Before acting on an event, read the subscription's row. If it says removed, a late checkout.session.completed retry shouldn't put them back.
  • Listen to the group too. Your Maytapi webhook receives info messages for membership changes, with the person in participant. A join arrives as group/add or group/invite (joined through a link; on some connections link joins also come as group/add), and a departure as group/remove or group/leave. Treat each pair the same way: move a row from invited to active on a join, and note departures without touching the subscription. The webhook events guide lists the payloads.
  • Keep the handler fast. If group calls make your Stripe endpoint slow, save the event and process it from a queue, then return 200 straight away.
Checklist for an idempotent Stripe to WhatsApp group sync: skip processed event IDs, check membership before adding, let the subscription table win, listen to group webhooks, keep the Stripe handler fast

To test the flow end to end, use the Stripe CLI: stripe listen --forward-to localhost:3000/stripe/webhook prints the webhook secret for local use, and stripe trigger checkout.session.completed sends a test event. Use a test group and your own second number while you're at it.

Adding and removing members changes the group, so on numbers in the stricter Safe Mode tiers these calls can be blocked or limited. Always read success and message instead of assuming the change happened.

Know the risk before you sell access. Maytapi is an unofficial API, so the connected number can be restricted by WhatsApp, and if it is, your paying members lose the admin that manages their group. Use a dedicated number rather than your personal one, keep a second admin or a fallback channel such as email, and tell buyers that group members can see each other's numbers.

Next steps

Stripe already knows who is paying. With two webhook events, a phone column and three Maytapi calls, your WhatsApp group knows it too, and nobody has to manage the member list by hand.

Published May 26, 2026 · Updated Oct 27, 2026 ← All posts
Start using the WhatsApp API

Get your key, send your first message in ten minutes

No approval, no templates, no card. Every endpoint is open for three days.