# How to move users to a new WhatsApp number after a ban

> WhatsApp banned your agent's number: chats do not move and the new number cannot write first. A playbook with webhook code to move users and restore history.

Source: https://whasp.ai/blog/whatsapp-number-replaced-migrate-users · 2026-10-20

## Key takeaways

- When WhatsApp bans a whasp number, you get a replacement at no cost with the same label, profile and paid period, announced by a number.replaced webhook event, but no chats move.
- The new number cannot write to anyone first (409 chat_not_open), so every user has to write to it, and the invitation must travel on a channel you own, such as email, push or an in-app banner.
- Send each user a link built from new_number.wa_link with a prefilled message that carries a one-time code, and invite in batches of about 25 a day, because a number allows 40 new chats per 24 hours.
- Key your history by your own user id, not by the number, so a returning person is recognized by phone or by code and the conversation continues where it stopped.
- Prepare before the first ban: a second channel collected at signup, a stable redirect link, that link in the profile about line, and a tested number.replaced handler.

When WhatsApp bans a number, the chats do not move: the replacement starts empty and cannot message anyone first. To move your users, tell them through a channel you own, send each one a wa.me link to the new number with a prefilled message, and recognize them when they write so you can restore their history from your own store.

## What happens when WhatsApp bans your agent's number?

When WhatsApp bans a whasp number for good, whasp retires it and moves you to a ready number at no cost. The replacement has the same label, the same profile (name, about, photo) and the same paid period. Nothing else carries over. No chats move, and the new number has none of the old history.

Your webhook receives a `number.replaced` event with the old number and the new number object, including its `wa_link`. Here is the event, trimmed:

```json
{
  "id": "evt_9dK3mQ2vLx",
  "type": "number.replaced",
  "created_at": "2026-10-18T19:00:00Z",
  "data": {
    "old_number": "+15555550100",
    "new_number": {
      "number": "+15555550102",
      "wa_link": "https://wa.me/15555550102",
      "label": "ava",
      "status": "active",
      "profile": { "name": "Ava", "about": "Your AI friend", "synced": true },
      "paid_until": "2026-11-17T19:00:00Z"
    }
  }
}
```

A `number.status_changed` event can arrive too, when the old number's health turns `critical`. Event order is not guaranteed, so do not depend on it. The banned number answers nobody from then on. To understand why it happened, read [why WhatsApp bans numbers](https://whasp.ai/blog/why-whatsapp-bans-numbers). For the first hour after a ban, including WhatsApp's own [page on account bans](https://faq.whatsapp.com/465883178708358), see [what to do when a number is banned](https://whasp.ai/blog/whatsapp-number-banned-what-to-do).

## Can the new number message your users first?

The new number cannot message anyone who has not written to it. A send to such a person returns 409 `chat_not_open`. This holds for every whasp number, a replacement included, and it is deliberate: cold messages are what get personal numbers banned, and a fresh number that opened hundreds of chats on its first day would invite the next ban.

So the move has to start with the user. They must tap a link and write. That makes the invitation the hard part, because it cannot come from the banned WhatsApp chat or from the new number. It has to travel on a channel you own. Once a user does write, sends without `from` go out from the new number, because the API uses the number a person last wrote to.

## What is the playbook for moving users?

The playbook has seven steps, and the code below covers the first six.

1. **Verify the event** and dedupe on its id (events can arrive twice, and the [Standard Webhooks spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) advises using `webhook-id` as an idempotency key).
2. **Find the affected users**: those whose chat lived on `old_number`. Save the number on each user from `message.received`, where `message.number` tells you which number got the message.
3. **Build one link per user**: `new_number.wa_link` plus `?text=` (WhatsApp's [click-to-chat format](https://faq.whatsapp.com/5913398998672934)) with a short message that carries a one-time code.
4. **Send the link on your own channel**: an in-app banner, a push notification or an email.
5. **Invite in batches**, most recently active first.
6. **Recognize the person when they write**, restore their history, and have the agent say the number changed.
7. **Remind once, then stop.** After a week, send one reminder to people who have not come back. The rest return when they next want the agent.

## How do you handle the event and send invitations?

Verify the signature on the raw body, dedupe on the event id, and start the invitations. This handler reuses the [Standard Webhooks](https://www.standardwebhooks.com) check from the whasp docs.

```ts
import { randomBytes } from "node:crypto";
import { Webhook } from "standardwebhooks";

type User = { id: string; phone: string; number: string; lastActive: number; invited?: boolean };

declare const users: Map<string, User>; // your database
declare function notify(user: User, link: string): Promise<void>; // email, push or in-app banner

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET ?? ""); // whsec_...
const seen = new Set<string>(); // event ids already handled
export const codes = new Map<string, string>(); // invite code -> user id
let moveFrom: { number: string; link: string } | null = null;
const DAILY_INVITES = 25; // below 80% of the 40 new chats a day, with room for sign-ups

export async function POST(request: Request) {
  const body = await request.text(); // the raw body: verify before parsing
  let event: any;
  try {
    event = wh.verify(body, Object.fromEntries(request.headers));
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }
  if (seen.has(event.id)) return new Response(null, { status: 204 });
  seen.add(event.id);

  if (event.type === "number.replaced") {
    const { old_number, new_number } = event.data;
    moveFrom = { number: old_number, link: new_number.wa_link };
    await inviteBatch(); // in production: answer 2xx first, send from a queue
  }
  return new Response(null, { status: 204 });
}

// Run once when the event arrives, then once a day until nobody is left.
export async function inviteBatch() {
  const target = moveFrom;
  if (!target) return;
  const batch = [...users.values()]
    .filter((u) => u.number === target.number && !u.invited)
    .sort((a, b) => b.lastActive - a.lastActive)
    .slice(0, DAILY_INVITES);
  for (const user of batch) {
    const code = randomBytes(4).toString("hex");
    codes.set(code, user.id);
    const text = encodeURIComponent(`Hi, it's me again (code ${code})`);
    await notify(user, `${target.link}?text=${text}`);
    user.invited = true;
  }
}
```

The code is random and single use, not the user id, because a prefilled message is visible, editable text and ids do not belong in links. Swap the Maps and the Set for database tables.

The batch size comes from the limits. A whasp number allows 40 new chats per 24 hours and 100 active chats per 30 days, and its health turns `at_risk` when a limit passes 80%, which is 32 new chats. About 25 invitations a day leaves room for new signups. A number with 100 active users takes four days, and the most recently active people go first because they are the ones who will notice.

### What should the invitation say?

The invitation should say that the agent has a new number, show where the link leads, and read like a message from you, not a warning. Skip the word "banned"; users care that Ava moved, not why.

> **Subject:** Ava has a new number
>
> Ava's old WhatsApp number stopped working. Tap below to carry on where you left off. The name and photo are the same, and she remembers everything. [Open the chat]

The tap opens WhatsApp with "Hi, it's me again (code 3f9a1c2e)" ready to send. Send the link only through your own app or your own domain. A text from an unknown number claiming to be Ava looks like phishing, and the new number cannot send one anyway. After the first week, count three things: invited, returned and silent. The silent ones get one reminder.

## How do you recognize a returning user and restore history?

Match the person by phone first and by code second, then load the conversation by your own user id. The phone number in `chat` is the same on the new number as on the old one, so a returning user is found without a code. The code covers the person who writes from a new phone.

```ts
declare const users: Map<string, User>;
declare const codes: Map<string, string>;
declare function reply(userId: string, text: string, opts: { moved: boolean }): Promise<void>; // your agent: loads history by user id

export async function onInbound(m: { chat: string; number: string; text: string | null }) {
  const code = m.text?.match(/\(code ([0-9a-f]{8})\)/)?.[1];
  const byCode = code ? users.get(codes.get(code) ?? "") : undefined;
  const user = byCode ?? [...users.values()].find((u) => u.phone === m.chat);
  if (!user) return; // a stranger: point them to signup

  if (code) codes.delete(code);
  user.phone = m.chat;
  const moved = user.number !== m.number;
  user.number = m.number; // this chat now lives on the new number
  // moved: have the agent say "It's me. My old number stopped working, and I still remember everything."
  await reply(user.id, m.text ?? "", { moved });
}
```

Call `onInbound` from your `message.received` handler. The design rule behind it: key history by user, never by number. If your memory table uses the whasp number as part of its key, a ban orphans every conversation. [Conversation memory](https://whasp.ai/blog/whatsapp-ai-agent-memory) shows a schema that survives a move.

Tell the person what happened. An agent that greets a returning user with its onboarding script feels like a stranger. One line is enough: it is the same agent, the old number stopped working, and the conversation continues.

## What if a user has no other channel?

If WhatsApp is your only channel with a user, you cannot reach them. The old chat is silent and the new number cannot write first. Three habits shrink that gap.

**Collect a second channel at signup.** An email or an app login costs one field. It is the only reliable path back.

**Publish one stable link.** Put a redirect on your own domain that asks `GET /v1/numbers/pick` for a healthy number at click time. Use that link where it has to last, such as your app, bios and QR codes, rather than a raw wa.me link.

```ts
// GET https://chat.example.com/ava: the one link you print, post and put in emails.
export async function GET() {
  const res = await fetch(
    `https://api.whasp.ai/v1/numbers/pick?text=${encodeURIComponent("Hi, it's me")}`,
    { headers: { Authorization: `Bearer ${process.env.WHASP_API_KEY}` } },
  );
  if (!res.ok) return new Response("Try again in a minute.", { status: 503 }); // 409 no_capacity
  const { wa_link } = await res.json();
  return Response.redirect(wa_link, 302);
}
```

**Put that link in the profile.** A replacement keeps the profile, and the about line allows up to 139 characters of plain ASCII for now. A short URL there travels to every future replacement and sits where users can read it. [Wa.me links and QR codes](https://whasp.ai/blog/whatsapp-onboarding-wa-me-links) covers placement.

## What should you set up before the first ban?

Any number driven like a person can be banned, so prepare while things are quiet.

- **Test the handler** with a sample `number.replaced` payload, including a duplicate and a bad signature.
- **Plan for a missed event.** whasp retries a delivery up to 12 times with backoff, then gives up and drops the body, so it cannot be sent again. `GET /v1/webhook/deliveries` lists the last 7 days. `GET /v1/numbers` lists your numbers: one you do not recognize is the replacement, and the `status` and `replaced_by` fields show what happened to the old one.
- **Run more than one number** when you can. A ban then moves only the users on that number. [How many numbers your agent needs](https://whasp.ai/blog/scale-whatsapp-ai-agent-multiple-numbers) has the arithmetic.
- **Key history by user,** and keep a phone number on every user record.

## What to do next

Add the `number.replaced` handler this week, then put the stable link into your app and your profile about line. Collect an email at signup from now on. The full API is in the [docs](https://whasp.ai/docs).

## Questions

### What happens to my chats when WhatsApp bans a number?

On whasp, nothing moves. The banned number is retired and you get a replacement with the same label, profile and paid period. The new number starts with no chats, and your system holds the history, so you restore it when each user writes in.

### Can the replacement number message my existing users first?

No. A send to someone who never wrote to that number returns 409 chat_not_open, and the rule holds for replacement numbers too. Users must write first, usually by tapping a wa.me link you send them by email, push or in your app.

### How many users can I move to the new number per day?

A whasp number allows 40 new chats per 24 hours and 100 active chats per 30 days, and turns at_risk above 80% of a limit. About 25 invitations a day keeps you under that and leaves room for new signups.

### How do I tell users the new number if I only have their WhatsApp chat?

You cannot reach them from the new number, and the banned chat is silent. Prepare earlier: collect an email or app login at signup, and publish a stable link on your own domain that always redirects to a healthy number.

### What if my webhook missed the number.replaced event?

whasp retries a delivery up to 12 times with backoff, then gives up and drops the body. List your numbers with GET /v1/numbers: one you do not recognize is the replacement, and the status and replaced_by fields show what happened to the old one.
