DEV Community

unifyport for UnifyPort

Posted on Originally published at unifyport.ai

Build a Reliable WhatsApp Group Join-Request Approval Queue with Node.js

A WhatsApp group join request arrives.

Your backend receives a webhook, checks a few rules, and immediately approves the user.

That sounds convenient, but it creates a fragile moderation system.

Webhooks can be delayed, retried, delivered out of order, or missed. A requester may also disappear from the pending list before your moderator acts.

The safer architecture is:

Webhook signal
    ↓
Durable reconciliation job
    ↓
Fetch current pending requests
    ↓
Store and review
    ↓
Approve or reject exact requester IDs
    ↓
Reconcile again
Enter fullscreen mode Exit fullscreen mode

The key principle is:

Use the webhook to wake up the system. Use the list endpoint to determine the current state.

This article demonstrates that pattern with Node.js and the UnifyPort API.

What WhatsApp still controls

WhatsApp group approval remains a group-admin feature.

When Approve new members is enabled, an administrator must approve people who request to join. According to the WhatsApp Help Center, this setting is disabled by default.

Your backend does not replace this permission model.

The connected WhatsApp account still needs the appropriate group permissions. Automation only helps answer the operational questions:

  • Who requested access?
  • Which group are they trying to join?
  • How did they arrive?
  • Who reviewed the request?
  • What decision was made?
  • Has WhatsApp accepted the decision?

Keep permission enforcement in WhatsApp and moderation policy in your own application.

The three API operations

A reliable approval queue uses three separate operations.

Operation Purpose
group.join_request webhook Low-latency notification that the pending state may have changed
List group join requests Retrieve the current set of pending requesters
Update group join requests Approve or reject selected requester IDs

Do not approve a user using only the data from the webhook.

Instead:

  1. Receive and verify the webhook.
  2. Enqueue a reconciliation job.
  3. Fetch the current pending list.
  4. Store the returned requester IDs.
  5. Let a moderator or policy make the decision.
  6. Submit the selected IDs to the update endpoint.

Example webhook event

A normalized WhatsApp group join-request event can look like this:

{
  "id": "evt_gjr_5e1c8a3f9b",
  "type": "group.join_request",
  "provider": "whatsapp",
  "account_id": "acc_8c21d0",
  "occurred_at": "2026-09-02T05:10:30Z",
  "data": {
    "conversation": {
      "id": "120363041234567890@g.us",
      "type": "group"
    },
    "requester": {
      "id": "8613912345678@lid",
      "type": "user"
    },
    "request_method": "invite_link",
    "event": {
      "kind": "group_join_request"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Useful fields include:

  • id: webhook event ID;
  • account_id: connected WhatsApp account;
  • data.conversation.id: provider group ID;
  • data.requester.id: requester’s provider ID;
  • data.request_method: how the request was initiated, when available.

Store the event ID so webhook retries remain idempotent.

Verify before trusting the event

Webhook signatures must be verified against the raw HTTP request body.

Do not parse JSON, serialize it again, and then verify the reconstructed value. Even semantically identical JSON can produce different bytes.

A simplified Express route might look like this:

app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const rawBody = req.body;

    if (!verifyWebhookSignature(req.headers, rawBody)) {
      return res.status(401).end();
    }

    const event = JSON.parse(rawBody.toString("utf8"));

    if (event.type !== "group.join_request") {
      return res.status(200).end();
    }

    await handleJoinRequestSignal(event);

    return res.status(200).end();
  },
);
Enter fullscreen mode Exit fullscreen mode

Keep signature verification isolated and test it using the exact header and signing contract documented for your webhook provider.

Make the receiver fast and idempotent

The webhook handler should not wait for a moderator decision.

Its job is to:

  1. Verify the signature.
  2. Check whether the event was already processed.
  3. Store minimal event information.
  4. Enqueue a reconciliation job.
  5. Return a successful response quickly.

For example:

async function handleJoinRequestSignal(event) {
  const groupId = event.data.conversation.id;

  await database.transaction(async (transaction) => {
    const inserted = await transaction.insertWebhookEventIfAbsent({
      eventId: event.id,
      type: event.type,
      accountId: event.account_id,
      occurredAt: event.occurred_at,
    });

    if (!inserted) {
      return;
    }

    await transaction.enqueueReconciliation({
      key: `${event.account_id}:${groupId}`,
      accountId: event.account_id,
      groupId,
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

The unique eventId prevents duplicate webhook deliveries from creating duplicate work.

The reconciliation key prevents several events for the same group from producing an unnecessary burst of identical jobs.

Do not use an in-memory queue in production

A Map or array is useful for a local example:

const pending = new Map();
Enter fullscreen mode Exit fullscreen mode

It is not a reliable production queue.

The state disappears when:

  • the process restarts;
  • the deployment replaces the instance;
  • the application crashes;
  • another instance receives the next webhook;
  • the platform scales the service horizontally.

Use durable infrastructure such as:

  • a database-backed jobs table;
  • Redis with a persistent queue library;
  • Amazon SQS;
  • Google Cloud Tasks;
  • RabbitMQ;
  • another queue with retry and visibility semantics.

The important requirement is that acknowledging the webhook must not discard the reconciliation work.

Fetch the current pending list

When the worker runs, fetch the group’s current join requests:

curl \
  "https://api.unifyport.ai/v1/accounts/acc_8c21d0/groups/join-requests?group_id=120363041234567890%40g.us" \
  -H "X-Api-Key: $UNIFYPORT_API_KEY"
Enter fullscreen mode Exit fullscreen mode

The group_id is passed as a query parameter and should be URL-encoded.

A Node.js worker can build the request safely:

async function listGroupJoinRequests({ accountId, groupId }) {
  const query = new URLSearchParams({
    group_id: groupId,
  });

  const response = await fetch(
    `https://api.unifyport.ai/v1/accounts/${encodeURIComponent(
      accountId,
    )}/groups/join-requests?${query}`,
    {
      headers: {
        "X-Api-Key": process.env.UNIFYPORT_API_KEY,
      },
    },
  );

  if (!response.ok) {
    throw new Error(`Unable to list join requests: ${response.status}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Keep UNIFYPORT_API_KEY on the server. Never expose it to browser code or commit it to the repository.

Persist requesters before reviewing them

The list response is the reliable source for pending requests.

Persist each returned requester using a stable uniqueness rule such as:

account_id + group_id + member_id
Enter fullscreen mode Exit fullscreen mode

A minimal record could contain:

const moderationRequest = {
  accountId: "acc_8c21d0",
  groupId: "120363041234567890@g.us",
  memberId: "8613912345678@lid",
  phone: null,
  requestMethod: "invite_link",
  requestedAt: "2026-09-02T05:10:30Z",
  status: "pending",
  decisionBy: null,
  decisionAt: null,
};
Enter fullscreen mode Exit fullscreen mode

Do not use a display name as the primary identifier. Display names are not necessarily unique or stable.

Use the id returned by the list endpoint as the value that later enters member_ids.

Reconcile with upserts

A worker can upsert every currently pending request:

async function reconcileGroupJoinRequests({ accountId, groupId }) {
  const body = await listGroupJoinRequests({
    accountId,
    groupId,
  });

  for (const request of body.data.items) {
    await upsertModerationRequest({
      accountId,
      groupId: body.data.group_id,
      memberId: request.id,
      phone: request.phone ?? null,
      requestedAt: request.requested_at,
      status: "pending",
    });
  }

  await markMissingRequestsForReview({
    accountId,
    groupId,
    currentMemberIds: body.data.items.map((request) => request.id),
  });
}
Enter fullscreen mode Exit fullscreen mode

Be careful with requests missing from a later list.

Their absence might mean they were:

  • approved elsewhere;
  • rejected elsewhere;
  • cancelled;
  • expired;
  • already processed by another moderator.

Do not automatically label every missing record as rejected unless the provider contract proves that interpretation.

A neutral state such as no_longer_pending is safer until you have more evidence.

Design the moderator workflow

The moderator UI should display enough information to make a decision without exposing unnecessary personal data.

Useful fields include:

  • group;
  • requester ID;
  • phone number, if returned and necessary;
  • request time;
  • request method;
  • current status;
  • assigned moderator.

A simple state machine could be:

pending
   ├── approving
   │      ├── approved
   │      └── pending
   └── rejecting
          ├── rejected
          └── pending
Enter fullscreen mode Exit fullscreen mode

The intermediate states prevent two moderators from acting on the same request simultaneously.

For example:

async function claimDecision({
  accountId,
  groupId,
  memberId,
  action,
  moderatorId,
}) {
  return database.moderationRequests.updateMany({
    where: {
      accountId,
      groupId,
      memberId,
      status: "pending",
    },
    data: {
      status: action === "approve" ? "approving" : "rejecting",
      decisionBy: moderatorId,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Continue only if exactly one record was updated.

Approve or reject exact requester IDs

To approve a requester:

curl -X POST \
  "https://api.unifyport.ai/v1/accounts/acc_8c21d0/groups/join-requests/update" \
  -H "X-Api-Key: $UNIFYPORT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "group_id": "120363041234567890@g.us",
    "action": "approve",
    "member_ids": [
      "8613912345678@lid"
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

To reject the request, change the action:

{
  "group_id": "120363041234567890@g.us",
  "action": "reject",
  "member_ids": [
    "8613912345678@lid"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The value in member_ids must come from the list endpoint.

Do not substitute:

  • a display name;
  • a guessed phone-number format;
  • the group conversation ID;
  • an identifier copied from an unrelated event.

Wrap the decision in a service

async function updateJoinRequests({
  accountId,
  groupId,
  action,
  memberIds,
}) {
  if (!["approve", "reject"].includes(action)) {
    throw new Error("action must be approve or reject");
  }

  if (!Array.isArray(memberIds) || memberIds.length === 0) {
    throw new Error("memberIds must contain at least one requester ID");
  }

  const response = await fetch(
    `https://api.unifyport.ai/v1/accounts/${encodeURIComponent(
      accountId,
    )}/groups/join-requests/update`,
    {
      method: "POST",
      headers: {
        "X-Api-Key": process.env.UNIFYPORT_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        group_id: groupId,
        action,
        member_ids: memberIds,
      }),
    },
  );

  if (!response.ok) {
    throw new Error(`Unable to update join requests: ${response.status}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Call the external API only after your application has claimed the pending decision.

After success, save an audit record:

await saveModerationDecision({
  accountId,
  groupId,
  memberIds,
  action,
  moderatorId,
  decidedAt: new Date().toISOString(),
});
Enter fullscreen mode Exit fullscreen mode

Never store the API key in the audit record.

Reconcile after the decision

A successful HTTP response confirms that the action request was accepted according to the API contract.

Your local queue should still reconcile the group afterward:

await updateJoinRequests({
  accountId,
  groupId,
  action: "approve",
  memberIds: [memberId],
});

await enqueueReconciliation({
  accountId,
  groupId,
});
Enter fullscreen mode Exit fullscreen mode

The next list operation confirms which requests remain pending.

This catches situations where:

  • another moderator acted first;
  • a requester cancelled;
  • the group state changed;
  • a batch contained stale requester IDs;
  • the local view was outdated.

Webhook plus periodic polling

A webhook-triggered reconciliation gives low latency, but periodic polling closes delivery gaps.

A practical strategy is:

Webhook received → reconcile that group immediately
Every few minutes → reconcile active groups
Before moderation → refresh the selected group
After moderation → reconcile again
Enter fullscreen mode Exit fullscreen mode

The polling interval depends on:

  • request volume;
  • acceptable moderation delay;
  • API limits;
  • number of active groups;
  • operational cost.

The goal is not to poll as frequently as possible. The goal is to ensure webhook delivery is not your only path to correct state.

Keep policy outside the transport layer

The webhook receiver should not contain business rules such as:

if (requesterPhone.startsWith("+86")) {
  approve();
}
Enter fullscreen mode Exit fullscreen mode

Transport code should verify, store, and enqueue.

Moderation policy belongs in a separate service where it can be:

  • reviewed;
  • tested;
  • audited;
  • changed without touching signature verification;
  • overridden by a human moderator.

For example:

const recommendation = await evaluateJoinRequest({
  groupId,
  memberId,
  requestMethod,
});

await saveRecommendation({
  groupId,
  memberId,
  recommendation,
});
Enter fullscreen mode Exit fullscreen mode

Even if the system generates an automated recommendation, keep the final action and its evidence auditable.

Failure scenarios to plan for

Duplicate webhooks

Use the webhook event ID as an idempotency key.

Missed webhooks

Run periodic list reconciliation.

Multiple events for one group

Deduplicate queued work using account_id + group_id.

Two moderators act simultaneously

Use an atomic status transition from pending to approving or rejecting.

Request disappears before review

Refresh the list and mark the local record no_longer_pending.

Worker crashes after the API request

Reconcile the list before retrying the external action.

Unauthorized account

Confirm the connected account has the required WhatsApp group-admin permissions. Automation cannot create permissions the account does not have.

Security and privacy checklist

Before deploying the queue, confirm that:

  • [ ] Webhook signatures are verified against the raw body.
  • [ ] Webhook event IDs are stored idempotently.
  • [ ] Reconciliation jobs use durable storage.
  • [ ] API keys remain server-side.
  • [ ] Logs do not contain API keys.
  • [ ] Moderator actions record who made the decision.
  • [ ] Only necessary requester data is displayed.
  • [ ] Requester IDs come from the list endpoint.
  • [ ] Missing requests are not automatically classified as rejected.
  • [ ] WhatsApp group permissions are checked operationally.
  • [ ] Periodic polling covers missed webhook delivery.
  • [ ] Post-decision reconciliation is enabled.

Takeaway

A webhook is an excellent wake-up signal, but it should not be your entire moderation database.

Build the approval queue around this sequence:

Verify
  ↓
Store
  ↓
Reconcile
  ↓
Review
  ↓
Approve or reject
  ↓
Reconcile again
Enter fullscreen mode Exit fullscreen mode

This design remains reliable when webhooks are retried, events arrive out of order, moderators work concurrently, or the pending WhatsApp state changes outside your application.

Use WhatsApp’s group setting as the permission boundary, the pending-list endpoint as the current state, and your own database as the moderation and audit record.

References


This article was adapted from an original UnifyPort technical guide with AI-assisted editing.

Top comments (0)