Self-hosted WhatsApp + email event invitations with RSVP and QR check-in.
Send invitations over WhatsApp (and email), collect Accept/Decline replies, auto-issue a
unique QR code to everyone who accepts, and scan those QRs at the door to mark attendance.
">https://img.shields.io/badge/node-%E2%89%A520-brightgreen"/>
Bundled sample artwork β swap in your own. No design skills required.
- π¨ WhatsApp invitations via the official Cloud API (Graph) β called directly with
fetch, no SDK. - β Accept / Decline RSVP β guests reply with a tap; the webhook records it.
- π« Automatic QR issuance β everyone who accepts gets a unique QR, composited onto your own artwork (auto box-detection finds where the QR goes).
- π· Door scanner PWA β open
/scanon any phone; first scan β green Registered, repeat β Already Attended. - ποΈ Any number of categories (general / partner / VIP / private / β¦) β each with its own template, artwork, and optional venue, all defined in one place.
- π Live admin + searchable guest list β counts, CSV export, search by name/email/phone, protected by a token.
- βοΈ Optional email channel (Resend) β run WhatsApp-only, email-only, or both.
- β±οΈ Throttled bulk sending, delivery/read tracking, and a check-in gate (attendance only counts after the event opens).
- π Any language + RTL β all copy lives in config; ships English, flip
UI_DIR=rtlfor Arabic/Hebrew/β¦ - π³ Self-hosted, one command β
docker compose upbrings up Postgres, Redis, and the app.
import list ββΊ guests (Postgres, delivery='queued')
β enqueue β [invites queue]
βΌ
send worker ββΊ WhatsApp template (your artwork + Accept/Decline buttons)
β webhook: button reply
accept βββββββ΄ββΊ qr worker ββΊ QR composited on your design ββΊ R2 ββΊ sent to guest
decline βββββββββΊ polite apology message (no location)
βΌ
guest opens QR link <BASE_URL>/c/<token> ββΊ door scanner marks attendance
(gated by CHECKIN_OPENS_AT)
| Guest list & campaign search | Check-in (before the event) |
|---|---|
![]() |
![]() |
Requirements: Docker (for Postgres + Redis). Node β₯ 20 if you want to run scripts outside Docker.
git clone <your-repo-url> gateqr && cd gateqr
cp .env.example .env # works as-is for local demo β no credentials needed
docker compose up # Postgres + Redis + app (migrates on start)Open http://localhost:3000 β you'll get the admin dashboard, /scan (the door scanner), and /list (searchable guest list). The app boots even with no WhatsApp/email/R2 keys; those channels simply stay off until you add credentials.
To try the QR pipeline with the bundled sample artwork:
npm install
npm run make-placeholder-designs # sample invitation + QR design in public/images/
npm run test-qr # composites a QR onto the sample β tmp/qr-*.pngEverything event-specific lives in lib/config.js and .env β nothing is hardcoded.
Define any number of categories in one place (CATEGORY_DEFS in lib/config.js). Each has a key, a label, optional free-text aliases (for importing spreadsheets in any language), and β via env β a template name, invitation image, QR design, and optional per-category location:
export const CATEGORY_DEFS = [
{ key: 'general', label: 'General' },
{ key: 'vip', label: 'VIP', aliases: ['premium', 'gold'] },
// add as many as you likeβ¦
];Per-category overrides use the key: TEMPLATE_VIP, INVITE_VIP, QR_DESIGN_VIP, LOCATION_URL_VIP, etc.
Drop your own images in public/images/ (or point INVITE_<CAT> / QR_DESIGN_<CAT> at them). The QR design just needs a white square where the code goes β then let the detector find it:
node scripts/detect-qr-box.js public/images/your-qr-design.png
# β prints x / y / size; paste into QR_LAYOUT (or the category's qrBox) in lib/config.jsInvitation body, button labels, QR caption, decline message, and all page/scanner text are in config (English defaults). Override any via env (TEMPLATE_BODY, BTN_ACCEPT, QR_CAPTION_BODY, β¦). For a non-English UI set UI_LANG and UI_DIR (e.g. UI_DIR=rtl), and TEMPLATE_LANG to match your approved WhatsApp template language.
The venue/map link is sent only in the QR message after a guest accepts β never to decliners. Set a default LOCATION_URL, per-category LOCATION_URL_<CAT>, or per-list overrides (LOCATION_BY_TAG).
/admin and /list are protected by ADMIN_TOKEN (HTTP Basic β any username + the token, or Authorization: Bearer <token>). If it's unset the pages are open (dev only) and the server warns at boot. Set it in production.
WhatsApp, email, and R2 each activate only when their keys are present. Missing keys disable that channel with a clear log line instead of crashing.
The one part that needs a bit of patience is Meta's template approval. Full walkthrough in docs/templates.md:
- Create a Meta app + a WhatsApp Business number (Cloud API), and copy the IDs/tokens into
.env. - Create one marketing template per category with an image header + two quick-reply buttons:
npm run create-templates. - Once approved, put the exact template names in
.env(TEMPLATE_<CAT>), point your webhook athttps://<public-host>/webhook, and subscribe to messages.
Header images are supplied at send time (media id), so you can change artwork without re-approval. New template text/buttons need a new approved template. Marketing templates hit a per-recipient frequency cap (
#131049, temporary β retry later);#131026= number not on WhatsApp.
| Command | What |
|---|---|
docker compose up |
Postgres + Redis + app (migrates, serves) |
docker compose --profile workers up -d worker |
start the send/QR workers (needs credentials) |
npm run migrate |
apply db/schema.sql |
npm run import -- <file.xlsx> [--sheet NAME] [--dry] |
import a guest list |
npm run enqueue -- [--limit N] [--category vip] [--all] |
queue invitations |
npm start / npm run workers |
run the server / both workers locally |
npm run make-placeholder-designs / npm run test-qr |
generate sample art / test the QR pipeline |
- Tokens: random 24-byte base64url, unique per guest, the only thing in the QR. Valid iff present in
guestsβ no signature math, not enumerable. Only staff resolve tokens, so guests can't self-check-in. - Idempotency: accept transitions
pending β acceptedexactly once (a second tap won't re-issue a QR); check-in is an atomicUPDATE β¦ WHERE checked_in_at IS NULL. - Webhook security:
POST /webhookverifiesX-Hub-Signature-256againstAPP_SECRET. - Ports: local Postgres is on host
:5433, Redis on:6380to avoid clashing with native installs.
GateQR is for legitimate, opt-in event invitations only β inviting people who expect to hear from you about an event. It is not for unsolicited messaging or spam. You are responsible for complying with WhatsApp/Meta's Business and messaging policies and with the privacy and anti-spam laws that apply to you and your guests. Be a good host, not a spammer.
Node.js (ES modules) Β· Fastify Β· BullMQ + Redis Β· PostgreSQL Β· Cloudflare R2 (S3-compatible) Β· qrcode + sharp Β· libphonenumber-js Β· xlsx Β· Resend (email). The WhatsApp Cloud API and Resend are called directly with fetch β no SDK wrappers.
- Optional public RSVP web page (in addition to WhatsApp buttons)
- Multi-event support in a single instance
- Pluggable storage (local disk / S3 / R2) for QR images
- Import from Google Sheets / CSV in the UI
Ideas and PRs welcome.
See CONTRIBUTING.md. In short: ES modules only, no SDK wrappers, config-driven, and never commit real data or secrets.
MIT Β© Hasan Hatem
Topics: whatsapp Β· whatsapp-cloud-api Β· rsvp Β· qr-code Β· event-management Β· check-in Β· invitations Β· self-hosted Β· nodejs Β· fastify




