# Deployment guide: WhatsApp Notification Gateway

This guide covers deploying the gateway as a Docker stack and connecting it to Moodle or any other system that can send an HTTP request. The Arabic [README.md](README.md) gives a shorter overview.

Verified on 2026-09-21 with Docker 29.8 / Compose v5.5, Evolution API **v2.3.7**, and Moodle 5.2 (the Thinkera stack).

---

## 1. What you are deploying

```
 LMS / any product                     wa-gateway stack (docker compose)
 ─────────────────                     ───────────────────────────────────────────────────────
 Moodle plugin ─┐                      gateway :8000  (FastAPI: /notify, /messages, /health,
 Nahel / CRM ───┼── POST /notify ────▶                /admin/qr, /webhook/evolution, /docs)
 cron script ───┘   X-API-Key             │ writes
                                          ▼
                                       postgres  (db "gateway": messages, contacts
                                          ▲        db "evolution": WhatsApp session data)
                                          │ polls
                                       worker  ── one message at a time, random delay,
                                          │        daily cap, retries, e-mail fallback
                                          ▼
                                       evolution :8080 ── WhatsApp Web protocol ──▶ WhatsApp
                                          │  (redis = Evolution cache)
                                          └── webhook: delivered / read / inbound STOP ──▶ gateway
```

| Service | Published on the host | Notes |
|---|---|---|
| `gateway` | `${GATEWAY_PORT}` → container 8000 (default **8882**) | The only service other products talk to |
| `evolution` | `127.0.0.1:8080` only | Manager UI at `/manager`; reach it through an SSH tunnel |
| `worker`, `postgres`, `redis` | not published | internal network only |

The API is **asynchronous**: `/notify` stores the message and returns `202` straight away, and the worker sends it later at a human pace. Callers never wait for WhatsApp.

---

## 2. Server requirements

- A Linux VM (Ubuntu 22.04/24.04 or Debian 12) with **2 vCPU, 2 GB RAM and 10 GB disk**. That is enough for thousands of messages a day. Windows with Docker Desktop is fine for development.
- Docker Engine with the Compose plugin: `curl -fsSL https://get.docker.com | sh`
- Outbound HTTPS to WhatsApp (`*.whatsapp.net`, `*.whatsapp.com`).
- A **dedicated phone number** on a phone you control, used for nothing else. See §9.
- Optional: an SMTP account for the e-mail fallback.

---

## 3. First deployment

```bash
# 1. Copy the folder to the server, e.g. /opt/wa-gateway
cd /opt/wa-gateway
cp .env.example .env

# 2. Generate every secret (run once per value)
openssl rand -hex 24
```

Edit `.env`:

| Variable | What to put |
|---|---|
| `POSTGRES_PASSWORD`, `EVOLUTION_API_KEY`, `ADMIN_KEY`, `WEBHOOK_TOKEN` | a fresh random value each |
| `GATEWAY_API_KEYS` | one key per calling product: `moodle:KEY1,nahel:KEY2`. The name is recorded on each message |
| `DEFAULT_COUNTRY_CODE` | country used for local numbers: **`20` for Egypt** (`01xxxxxxxxx` → `201xxxxxxxxx`), `966` for KSA (`05xxxxxxxx` → `9665xxxxxxxx`) |
| `SITE_NAME` | shown in messages as `{site}` |
| `GATEWAY_PORT` | host port. Use **`127.0.0.1:8882`** to keep it off the network (see §4) |
| `EVOLUTION_IMAGE` | **pin a version**, e.g. `evoapicloud/evolution-api:v2.3.7`. `latest` can change under you on the next pull |
| `DAILY_CAP` | `50` for a new number, then raise it weekly (§9) |
| `SMTP_*` | optional e-mail fallback; leave `SMTP_HOST` empty to disable |

```bash
# 3. Start
docker compose up -d --build
docker compose ps          # postgres, redis and gateway show "healthy"; evolution and worker show "Up"
docker compose logs -f worker
```

> `postgres-init/01-gateway-db.sql` creates the `gateway` database **only when the `pgdata` volume is created for the first time**. If you reuse an old volume and the gateway logs `database "gateway" does not exist`, create it by hand:
> `docker compose exec postgres psql -U wagw -d evolution -c "CREATE DATABASE gateway;"`

---

## 4. Choose how the LMS reaches the gateway

Choose one topology. **Do not expose port 8882 to the internet without TLS and an allowlist.** The API key is the only protection.

### A. LMS runs in Docker on the same host (recommended; this is Thinkera's setup)

Put the gateway on the LMS's Docker network. No host port is needed.

```bash
docker network ls                               # find the LMS network, e.g. moodle_default
echo "LMS_DOCKER_NETWORK=moodle_default" >> .env
docker compose -f docker-compose.yml -f docker-compose.lms-network.yml up -d
```

The LMS then uses **`http://wa-gateway:8000`** as the gateway URL. Check it from inside the LMS container:

```bash
docker exec thinkera-moodle php -r 'echo file_get_contents("http://wa-gateway:8000/health");'
```

Set `GATEWAY_PORT=127.0.0.1:8882` in this topology, so the host port is reachable only locally (you still need it for the QR page).

> The LMS network must exist before the gateway starts. If the LMS stack is taken down with `docker compose down`, run the gateway's `up -d` command again afterwards so it rejoins the network.

### B. LMS on the same host, not in Docker

Gateway URL: `http://127.0.0.1:8882`, with `GATEWAY_PORT=127.0.0.1:8882`.

### C. LMS on another server

Pick one of these:

1. **Private network or VPN** (WireGuard, a cloud VPC). Gateway URL: `http://10.x.x.x:8882`. Firewall the port so only the LMS server can reach it:
   `ufw allow from <LMS_IP> to any port 8882 proto tcp`
2. **HTTPS reverse proxy with an IP allowlist.** A Caddy example, where Caddy obtains the certificate automatically:

   ```caddyfile
   wa.example.com {
       @lms remote_ip 203.0.113.10          # LMS server IP(s)
       handle /webhook/* { respond 404 }    # Evolution calls the webhook internally; never from outside
       handle /admin/*   { respond 404 }    # use an SSH tunnel for admin pages
       handle @lms       { reverse_proxy 127.0.0.1:8882 }
       respond 403
   }
   ```

   Gateway URL: `https://wa.example.com`.

---

## 5. Link the WhatsApp number (once)

Open `http://<host>:8882/admin/qr?key=<ADMIN_KEY>`. On a remote server, use an SSH tunnel so the admin key never crosses the network in clear text:

```bash
ssh -L 8882:127.0.0.1:8882 user@server
# then open http://localhost:8882/admin/qr?key=<ADMIN_KEY> on your own machine
```

The page creates the Evolution instance, registers the webhook and shows a QR code. On the phone, open WhatsApp → **Linked devices** → **Link a device** and scan it. The page refreshes every 20 seconds and then shows **"✅ WhatsApp connected"**.

Check the result with `curl http://127.0.0.1:8882/health`. You should see `"ok": true, "whatsapp_state": "open"`.

The session is stored in the `evolution_instances` volume and survives restarts. If you lose that volume, you have to scan the QR code again.

---

## 6. Smoke test

```bash
KEY=<the moodle key from GATEWAY_API_KEYS>

curl -X POST http://127.0.0.1:8882/notify \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"event":"course_enrolled","to":"01XXXXXXXXX","locale":"ar",
       "vars":{"firstname":"Reda","course":"Python 101","url":"https://lms.example.com"},
       "idempotency_key":"smoke-1"}'
# -> {"id":"…","status":"queued","duplicate":false}

curl -H "X-API-Key: $KEY" "http://127.0.0.1:8882/messages?limit=5"
docker compose logs -f worker        # "... -> sent whatsapp"
```

Use **your own number** for this test. A queued message is really sent as soon as WhatsApp is connected.

---

## 7. Moodle integration

The plugin `moodle-plugin/local/wagateway` sends no HTTP from Moodle pages. Its observers queue an **ad hoc task**, and Moodle cron runs that task and calls `/notify`. **Moodle cron must be running.**

| Moodle event | Gateway event | Template variables |
|---|---|---|
| `\core\event\user_created` | `user_registered` | firstname, fullname, site, url |
| `\core\event\user_enrolment_created` | `course_enrolled` | + course, course url |
| enrolment + a core `payments` row (`enrol_fee`) in the last 10 minutes | `payment_received` | + amount, currency |
| `\core\event\course_completed` | `course_completed` | firstname, course |

### 7.1 Install on a standard Moodle

| Moodle version | Copy the plugin to |
|---|---|
| 4.0 – 5.0 | `<moodle>/local/wagateway` |
| 5.1 and later (the `public/` layout) | `<moodle>/public/local/wagateway` |

Then run **Site administration → Notifications**, or from the command line:

```bash
php admin/cli/upgrade.php --non-interactive
```

You can also zip the `wagateway` folder and upload it under **Site administration → Plugins → Install plugins**.

### 7.2 Install on the Thinkera Docker stack

Thinkera's image copies plugins from `platform/moodle/plugins/` into the code tree at start, using the explicit list in `php/sync-plugins.sh`. Adding the folder is not enough on its own:

```bash
# 1. Put the plugin next to the other Thinkera plugins
cp -r wa-gateway/moodle-plugin/local/wagateway  platform/moodle/plugins/local/wagateway

# 2. Add this line to platform/moodle/php/sync-plugins.sh, below "copy local/thinkera ...":
#      copy local/wagateway       local/wagateway

# 3. Rebuild the image (sync-plugins.sh is baked into it), then install
cd platform/moodle
docker compose up -d --build moodle cron
docker compose exec moodle sync-plugins --live
docker compose exec -u www-data moodle php admin/cli/upgrade.php --non-interactive
```

Both `moodle` and `cron` must get the plugin, because they share one component cache (see the comment in `sync-plugins.sh`).

### 7.3 Configure

In **Site administration → Plugins → Local plugins → WhatsApp notification gateway**, or from the CLI:

```bash
C="php admin/cli/cfg.php --component=local_wagateway"
$C --name=gatewayurl --set=http://wa-gateway:8000     # topology A; otherwise the URL from §4
$C --name=apikey     --set=<moodle key>
$C --name=phonefield --set=phone2                     # Thinkera keeps mobiles in phone2
$C --name=enabled    --set=1
```

**Phone field:** use the field that actually holds mobile numbers. Thinkera's `enrol_instapay` reads **`phone2`**, falling back to a custom profile field with the short name `mobile`. For that custom field, set `phonefield=custom` and `customfield=mobile`.

### 7.4 Test from Moodle

Enrol a test user who has your phone number, then run the queued tasks straight away:

```bash
docker compose exec -u www-data moodle php admin/cli/adhoc_task.php --execute
# local_wagateway: course_enrolled queued for user 42
```

Failed calls (gateway down, wrong key) throw an exception, and Moodle retries the task with backoff. A `422` (invalid phone number) is logged and not retried.

### 7.5 Payments with InstaPay (Thinkera)

`payment_received` only detects **core payment** (`enrol_fee`). With `enrol_instapay`, an admin approves the receipt, and `manager::approve()` enrols the student. That fires `user_enrolment_created`, so the student **does** get the `course_enrolled` message, but no payment receipt. To add one, have `enrol_instapay` call the gateway on approval (for example next to `notify_student()`) with `event=payment_received`, `vars.amount`, `vars.currency`, and `idempotency_key=instapay:<requestid>`. The ad hoc task class in this plugin already contains the HTTP code to copy.

### 7.6 Adding a Moodle event

1. Add the event with `ar`/`en` variants to `gateway/templates.yml`. The file is re-read on the fly, so no restart is needed.
2. Add an observer to `db/events.php` and `classes/observer.php`, plus a setting checkbox.
3. Raise the number in `version.php` and run the upgrade.

---

## 8. Integrating any other product

Any system that can send an HTTP request can use the gateway. Give each product its own key (`GATEWAY_API_KEYS=moodle:…,nahel:…,crm:…`), so you can revoke one without affecting the others and see which product sent each message.

### API contract

`POST /notify`, with the header `X-API-Key: <key>`:

| Field | Required | Meaning |
|---|---|---|
| `event` | yes | key in `templates.yml` (`course_enrolled`, …) |
| `to` | yes | any format: `+2010…`, `010…`, `002010…`, `2010…@s.whatsapp.net`, Arabic-Indic digits |
| `vars` | no | values for the template placeholders |
| `locale` | no | `ar` / `en` (default `DEFAULT_LOCALE`) |
| `text` | no | raw text that replaces the template. Avoid it for bulk sends: varied template wording lowers the ban risk |
| `email` | no | enables the e-mail fallback for this message |
| `idempotency_key` | recommended | same key → same message, never sent twice. Use something like `<system>:<event>:<record id>` |

| Response | Meaning |
|---|---|
| `202 {"id","status":"queued","duplicate":false}` | accepted |
| `202 {…,"duplicate":true}` | this `idempotency_key` was seen before; nothing new was queued |
| `401` | wrong or missing API key |
| `422` | invalid phone number, or an unknown event with no `text` → **do not retry** |
| `5xx` or a timeout | retry later; the `idempotency_key` makes retries safe |

Status of a message: `GET /messages/{id}` → `queued → sending → sent → delivered → read`, or `failed` / `skipped`. List recent messages: `GET /messages?status=failed&to=010…&limit=50`. The OpenAPI UI is at `/docs`.

### Client examples

**PHP**
```php
$ch = curl_init(getenv('WA_GATEWAY_URL') . '/notify');
curl_setopt_array($ch, [
    CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: ' . getenv('WA_GATEWAY_KEY')],
    CURLOPT_POSTFIELDS => json_encode([
        'event' => 'course_enrolled', 'to' => $user->mobile, 'locale' => 'ar',
        'vars' => ['firstname' => $user->firstname, 'course' => $course->name, 'url' => $courseUrl],
        'idempotency_key' => "myapp:enrol:{$enrolmentId}",
    ]),
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);   // 202 ok · 422 drop · other: retry later
```

**Python**
```python
import os, requests
r = requests.post(f"{os.environ['WA_GATEWAY_URL']}/notify", timeout=15,
                  headers={"X-API-Key": os.environ["WA_GATEWAY_KEY"]},
                  json={"event": "course_completed", "to": "01012345678", "locale": "ar",
                        "vars": {"firstname": "Mona", "course": "Data 101"},
                        "idempotency_key": f"myapp:done:{completion_id}"})
if r.status_code not in (202, 422):
    r.raise_for_status()            # let your job queue retry
```

**Node.js**
```js
const r = await fetch(`${process.env.WA_GATEWAY_URL}/notify`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-API-Key": process.env.WA_GATEWAY_KEY },
  body: JSON.stringify({ event: "user_registered", to: phone, vars: { firstname }, idempotency_key: `app:reg:${userId}` }),
  signal: AbortSignal.timeout(15000),
});
```

Call the gateway from a **background job or queue** in the calling product, not inside a user's page request. That way a gateway outage never slows your pages, and failed calls can be retried.

---

## 9. Reducing the ban risk

Evolution API uses the unofficial WhatsApp Web protocol, so the number **can be banned**. The settings below reduce that risk. They do not remove it.

- Use a dedicated number. Use it by hand for 1–2 weeks before automating it.
- Set `DAILY_CAP=50` at first, then 100 → 200 → … one step per week. Keep `MIN_DELAY_SECONDS` at 5 or more.
- Send transactional messages only. Never send marketing from this number.
- Let students message you first: put a `https://wa.me/20XXXXXXXXXX?text=تفعيل` button on the sign-up or welcome page. Any inbound message marks that number as opted in. `REQUIRE_OPT_IN=true` sends WhatsApp only to numbers that opted in; everyone else gets e-mail.
- `إيقاف`/`STOP` from a student stops their messages immediately, and `تفعيل`/`START` turns them back on.
- Keep a warmed-up spare number. Switching over means running `POST /admin/logout?key=…` and scanning the QR code with the new phone.
- For government or university clients, use the official **Meta Cloud API** (`PROVIDER=cloud_api`; see README §7). The LMS side does not change.

---

## 10. Operations

### Logs
```bash
docker compose logs -f worker            # every send: "<id> <event> <phone> -> sent whatsapp"
docker compose logs -f gateway           # API calls, webhook
docker compose logs --tail 100 evolution # WhatsApp connection problems
```

### Monitoring
`GET /health` returns HTTP 200 **even when WhatsApp is disconnected**. Set your monitor (Uptime Kuma, etc.) to a **keyword** check for `"ok":true`. `ok:false` usually means the phone logged the session out or the number was banned.

The daily cap counts from **UTC midnight**, which is 02:00 or 03:00 in Cairo and 03:00 in Riyadh.

### Backup
```bash
# message history and contacts (opt-in / opt-out state)
docker compose exec -T postgres pg_dump -U wagw gateway | gzip > gateway-$(date +%F).sql.gz

# WhatsApp session (lets you restore without scanning the QR again)
docker run --rm -v wa-gateway_evolution_instances:/v -v "$PWD":/b alpine tar czf /b/evolution-instances-$(date +%F).tgz -C /v .
```
Volume names use the compose project name (the folder name, `wa-gateway` by default). `docker volume ls` shows them.

### Updating
```bash
# change EVOLUTION_IMAGE to the new pinned tag first, then:
docker compose pull evolution
docker compose up -d --build
docker compose ps && curl -s http://127.0.0.1:8882/health
```
Editing `templates.yml` needs no restart. Editing `.env` needs `docker compose up -d`, which recreates the affected containers.

### Restarts
All services use `restart: unless-stopped`. After a crash, the worker moves messages stuck in `sending` back to `queued`. A message that was actually sent just before the crash may therefore go out a second time. This is rare.

---

## 11. Troubleshooting

| Symptom | Cause / fix |
|---|---|
| `/health` → `whatsapp_state: "no_instance"` | The QR page has not been opened yet (§5) |
| QR page shows "No QR yet" | Old Evolution image: pin a current tag, then `docker compose pull evolution && docker compose up -d`. Check `docker compose logs evolution` |
| Messages stay `queued`, error `whatsapp not connected` | The phone is offline or logged out; rescan the QR. After `FALLBACK_AFTER_MINUTES` they go to e-mail, or to `failed` if there is no e-mail |
| error `daily cap reached` | Expected; they are sent the next day. Raise `DAILY_CAP` slowly |
| `failed` / `number is not on WhatsApp` | Wrong country code (check `DEFAULT_COUNTRY_CODE`) or a number without WhatsApp |
| Moodle task log: `HTTP 0` | Moodle cannot reach the gateway URL. Test with `curl` from the Moodle host or container; for topology A, rerun the override `up -d` |
| Moodle task log: `HTTP 401` | The plugin's API key does not match `GATEWAY_API_KEYS` |
| Moodle: `user X has no phone number, skipped` | Wrong `phonefield`; see §7.3 |
| Nothing happens in Moodle at all | Cron is not running, the plugin is disabled, or the event checkbox is off |
| Gateway logs `database "gateway" does not exist` | See the note at the end of §3 |
| Delivered/read status never updates | Webhook not registered: open the QR page once more (it re-registers the webhook), and check that `WEBHOOK_TOKEN` did not change |

---

## 12. Go-live checklist

- [ ] All `change-me-*` values replaced; `.env` not committed to git
- [ ] `EVOLUTION_IMAGE` pinned to a version
- [ ] `DEFAULT_COUNTRY_CODE` correct for the students' country
- [ ] Gateway port not open to the internet (topology A/B, firewall allowlist, or TLS proxy)
- [ ] Dedicated, warmed-up number linked; `/health` shows `ok:true`
- [ ] `DAILY_CAP` starts at 50; SMTP fallback configured and tested
- [ ] Moodle cron running; plugin enabled; `phonefield` checked against real users
- [ ] A test enrolment reached a real phone
- [ ] Nightly backup of the `gateway` database and the `evolution_instances` volume
- [ ] Keyword monitor on `/health`
