/paymentPage/callback and deep links)Payment gateway redirects and bookmarked URLs hit paths like
/paymentPage/callback?TRANSACTION_ID=...&RECEIPT_TOKEN=.... In Chrome
DevTools, the document request often shows 404 Not Found, while
the payment callback logic still runs (console logs from
PaymentCallback.js, success/cancel/fail UI).
This document explains why that happens, how it relates to the current
Apache vhost (reverse proxy to 192.168.8.x:8080), and what to change for
a proper SPA setup (document 200 + unchanged browser URL and query
string).
Related docs:
status-code: 404 on SPA shell (including /paymentPage/callback)/api/* must not be rewritten to index.html| Observation | Typical meaning |
|---|---|
GET /paymentPage/callback?... → 404 |
Origin (or Apache) has no file at that path |
Response Server: GLD Server B, Content-Type: text/html |
Apache / front tier answered |
| Console still logs transaction data | React loaded (often via index.html body with wrong status) |
| Network shows red 404 on the document | Cosmetic for users if the shell loads; bad for monitoring, SEO, and some gateways |
Example URL (card payment return from GCIS):
https://pnsps.gld.gov.hk/paymentPage/callback?TRANSACTION_ID=...&RECEIPT_TOKEN=...
The client route exists and is wired to the callback page:
| Piece | Location |
|---|---|
Route paymentPage/callback |
src/routes/PublicUserRoutes.js |
| Component | src/pages/Payment/PaymentCallback.js |
Card returnurl |
src/pages/Payment/Card/index.js (/paymentPage/callback) |
React Router only runs after the browser loads the SPA shell
(index.html + JS). The first request is a full-page GET from
the payment gateway (or user bookmark). Apache / Tomcat must serve the
shell for that path; the router never sees a request that never delivered
HTML.
Real callback work is POST to the backend after the page loads:
| Step | API (via ApiPathConst.js) |
|---|---|
| 1 | POST /api/payment/api/updatepayment (UPDATE_PAYMENT_API) |
| 2 | POST /api/payment/callback/status (PAYMENT_CALLBACK_STATUS_API) |
Backend (PNSPS-backend): PaymentController — /api/updatepayment,
/callback/status under the payment module (full path includes
server.servlet.contextPath: /api and controller prefix).
Typical vhost (httpd-vhosts.conf) for pnspsdev.gld.gov.hk:
ProxyPass /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/
ProxyPass /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/
ProxyPass /api/ http://192.168.8.6:8080/api/
ProxyPass / http://192.168.8.6:8080/
ProxyPassReverse / http://192.168.8.6:8080/
With only this:
GET /paymentPage/callback is forwarded to 192.168.8.6:8080 as the
same path./paymentPage/callback on the origin.index.html in
the body — “SPA shell 404”).window.location.search, and complete payment.So: logic can work while the document status stays 404.
Spring Boot in this repo is API-only (contextPath: /api, static
resource mappings disabled in application.yml). The React build is
served separately (commonly Tomcat or static root on :8080). SPA
fallback must be configured on Apache and/or that static tier — not
in Spring MVC alone.
/api/ — Always proxy to the backend. Never rewrite API paths to
index.html. (See csp-api-logout-404-follow-up.md.)ProxyPass prefixes
(/paymentrecon-dept_web/, /paymentrecon-portal_web/, etc.) before
the catch-all /.GET requests that are not files and not API,
serve index.html with HTTP 200.?TRANSACTION_ID=...&RECEIPT_TOKEN=...
in the browser URL. Use internal rewrite to index.html, not an
external redirect that drops params.ErrorDocument 404 /index.html as the primary SPA strategy:
body may be the app while status remains 404 (same noise as today).Deploy npm run build output to the Apache host. Proxy only API (and
other backends). Remove catch-all ProxyPass / to the app server for
the main site.
<VirtualHost *:443>
ServerName pnspsdev.gld.gov.hk
# ... SSLEngine, certificates ...
DocumentRoot /var/www/pnsps/build
<Directory /var/www/pnsps/build>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
# Apache 2.4.16+ — unknown paths → index.html, status 200
FallbackResource /index.html
</Directory>
ProxyPreserveHost On
ProxyPass /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/
ProxyPassReverse /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/
ProxyPass /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/
ProxyPassReverse /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/
ProxyPass /api/ http://192.168.8.6:8080/api/
ProxyPassReverse /api/ http://192.168.8.6:8080/api/
# Do not ProxyPass / to 8080 for the main SPA when using DocumentRoot
</VirtualHost>
Deploy: After each frontend release, sync build/ to
/var/www/pnsps/build (or your chosen path) on the Apache server.
Pros: Clear split — static on Apache, API on Spring. Easy to reason about 200 vs 404.
Cons: Requires a deploy path for static files on the Apache tier.
ProxyPass / — rewrite deep links to index.html before proxyUse when the React app must stay on 192.168.8.6:8080 and you only
control Apache. Rewrite eligible GET requests to /index.html on the
upstream; browser URL and query string stay unchanged ([PT,QSA]).
Add inside the same vhost, after enabling RewriteEngine (you may
already have TRACE/TRACK rules):
RewriteEngine on
# Existing: deny TRACE / TRACK
RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)
RewriteRule .* - [F]
# Do not SPA-rewrite API or other proxied applications
RewriteCond %{REQUEST_URI} ^/api/ [OR]
RewriteCond %{REQUEST_URI} ^/paymentrecon-dept_web/ [OR]
RewriteCond %{REQUEST_URI} ^/paymentrecon-portal_web/
RewriteRule ^ - [L]
# Pass through static assets (Create React App uses /static/...)
RewriteCond %{REQUEST_URI} ^/static/ [OR]
RewriteCond %{REQUEST_URI} \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|map|json|txt|webmanifest)$ [NC]
RewriteRule ^ - [L]
# SPA fallback: upstream receives GET /index.html; browser keeps /paymentPage/callback?...
RewriteCond %{REQUEST_METHOD} GET
RewriteCond %{REQUEST_URI} !^/index\.html$
RewriteRule ^ /index.html [PT,QSA,L]
# Existing ProxyPass (specific paths first)
ProxyPass /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/
ProxyPassReverse /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/
ProxyPass /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/
ProxyPassReverse /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/
ProxyPass /api/ http://192.168.8.6:8080/api/
ProxyPassReverse /api/ http://192.168.8.6:8080/api/
ProxyPass / http://192.168.8.6:8080/
ProxyPassReverse / http://192.168.8.6:8080/
If rewrite and proxy order behaves differently on your Apache version, try explicit proxy on the last rule:
RewriteRule ^ /index.html [P,QSA,L]
(requires mod_proxy and correct module load order).
Pros: No change to where the WAR/static tree lives on 8080.
Cons: Rewrite + ProxyPass interaction must be tested per environment;
static file detection is by URL pattern, not upstream filesystem.
:8080)If Apache cannot change, configure the tier that serves / on port 8080
(Tomcat RewriteValve, filter, or error handling) so unknown GET
paths return index.html with 200. Same idea as Options A/B; only the
layer moves.
Replace host with DEV/UAT/PROD as appropriate (pnspsdev.gld.gov.hk,
pnsps.gld.gov.hk, etc.).
curl -sS -D - -o /dev/null \
"https://pnspsdev.gld.gov.hk/paymentPage/callback?TRANSACTION_ID=test&RECEIPT_TOKEN=test"
| Expected after fix | Before fix (typical) |
|---|---|
200, Content-Type: text/html |
404, HTML body may still be SPA |
curl -sS -D - -o /dev/null -X POST \
"https://pnspsdev.gld.gov.hk/api/payment/callback/status" \
-H "Content-Type: application/json" \
-d '{}'
Expect a JSON error from Spring (4xx), not index.html HTML.
Status is not 404 from a missing static file.
curl -sS -D - -o /dev/null \
"https://pnspsdev.gld.gov.hk/static/js/main.<hash>.chunk.js"
Use a real hash from the deployed index.html. Expect 200.
curl -sS -D - -o /dev/null "https://pnspsdev.gld.gov.hk/login"
curl -sS -D - -o /dev/null "https://pnspsdev.gld.gov.hk/dashboard"
Expect 200 HTML shell when logged-out routes are publicly reachable (may redirect in browser; curl shows initial response).
/paymentPage/callback?... → 200.POST .../api/payment/api/updatepayment and
POST .../api/payment/callback/status → success responses per business rules.PaymentCallback.js.sudo apachectl configtest
sudo systemctl reload apache2
Use httpd / apachectl as on your GLD hosts.
paymentPage/callback is under PublicUserRoutes, which is registered
only when the user is logged in as IND or ORG (src/routes/index.js).
If the session expires on the gateway, the user may hit /login instead
of the callback page. That is separate from the document 404; test with a
fresh login if callback APIs never fire.
| Flow | Return path | Route file |
|---|---|---|
| Card | /paymentPage/callback |
PublicUserRoutes.js |
| FPS (PASG) | /paymentPage/fps/fpscallback |
PublicUserRoutes.js (+ test route in LoginRoutes.js) |
This follow-up focuses on card returnurl to /paymentPage/callback.
PaymentCallback.js uses
params.get("RECEIPT_TOKEN").replace("%3D", "=") without a null check.
Missing RECEIPT_TOKEN throws before API calls. Harden separately if
gateways ever omit the param.
| Idea | Why |
|---|---|
| Add only a React route | Does not fix the first GET to Apache/Tomcat |
Redirect callback URL to / |
Loses query params unless carefully preserved |
Rewrite /api/ to index.html |
Breaks payment POSTs, logout, login, etc. |
Rely on 404 + index.html body |
Payment may work; monitoring and CSP reports stay noisy |
| Change CSP for this | Not a CSP block; document status is HTTP routing |
| Option | Where SPA fallback lives | Remove ProxyPass /? |
|---|---|---|
| A | Apache DocumentRoot + FallbackResource |
Yes (for main site) |
| B | Apache RewriteRule → /index.html + existing proxy |
No |
| C | Tomcat / origin on 8080 | No Apache change |
Recommended: Option A if operations can publish build/ to Apache;
Option B if the static tree must remain on 192.168.8.6:8080.
After implementation, /paymentPage/callback should show 200 on the
document request while behaviour of PaymentCallback.js and payment APIs
stays the same.
| Piece | Location |
|---|---|
| Callback route | PNSPS-frontend-MaterialUI/src/routes/PublicUserRoutes.js |
| Callback UI + API calls | PNSPS-frontend-MaterialUI/src/pages/Payment/PaymentCallback.js |
| Card return URL | PNSPS-frontend-MaterialUI/src/pages/Payment/Card/index.js |
| API constants | PNSPS-frontend-MaterialUI/src/utils/ApiPathConst.js |
| Route auth wiring | PNSPS-frontend-MaterialUI/src/routes/index.js |
| Payment APIs | PNSPS-backend/.../payment/web/PaymentController.java |
| API context path | PNSPS-backend/src/main/resources/application.yml (/api) |
| Date | Note |
|---|---|
| 2026-10-14 | Initial follow-up from payment callback 404 investigation and DEV vhost review |