Payment Flow (Viva Smart Checkout)
PCMR.gr uses Viva Smart Checkout — a hosted payment page integration. Customers pay on Viva's page; the platform verifies the result.
Environment URLs
| Environment | Auth URL | API URL | Checkout URL |
|---|---|---|---|
| Demo | https://demo-accounts.vivapayments.com | https://demo-api.vivapayments.com | https://demo.vivapayments.com |
| Production | https://accounts.vivapayments.com | https://api.vivapayments.com | https://www.vivapayments.com |
Critical: Auth, API, and Checkout are different subdomains. Using demo.vivapayments.com for API calls hangs — must use demo-api.vivapayments.com. This is a common mistake when configuring a new environment.
Configure via env vars:
VIVA_AUTH_URL— OAuth2 token endpoint baseVIVA_BASE_URL— API base (create order, verify transaction)
Full Flow (15 steps)
1. Customer clicks "Pay" on portal order detail page
2. Browser POSTs to /api/payments/create-order (Next.js route handler)
3. Next.js forwards to NestJS POST /payments/create-order
4. NestJS validates: session valid, order belongs to user, paymentStatus === awaiting_payment, orderStatus === confirmed
5. NestJS calls OAuth2 client_credentials to VIVA_AUTH_URL/connect/token (token cached 25s)
6. NestJS POSTs to VIVA_BASE_URL/checkout/v2/orders:
amount: order.total * 100 (Viva API expects amount in CENTS for create)
merchantTrns: orderId
customerTrns: order.displayName
sourceCode: VIVA_SOURCE_CODE
successUrl: {FRONTEND_URL}/payment/success
failureUrl: {FRONTEND_URL}/payment/failure
7. Viva returns { orderCode: string }
8. NestJS saves orderCode to Order.vivaOrderCode in DB
9. NestJS returns { orderCode } to browser
10. Browser redirects to: {CHECKOUT_URL}/web/checkout?ref={orderCode}
11. Customer pays on Viva's hosted checkout page
12. Viva redirects to: {successUrl}?t={transactionId}&s={orderCode}
13. Success page reads ?t and POSTs to /api/payments/verify with { transactionId, orderCode, orderId }
14. NestJS GETs VIVA_BASE_URL/checkout/v2/transactions/{transactionId}
15. Verification checks:
- statusId === 'F' (Finalized = successful payment)
- Math.abs(tx.amount - order.total) <= 0.01 (amounts in EUROS — not cents!)
- tx.merchantTrns === orderId (when available)
16. On success: Order.paymentStatus → paid, Order.invoiceReference = transactionId
17. Sends paymentConfirmedTemplate email to customer
Amount Handling
Critical difference: Viva's POST /checkout/v2/orders (create) expects amounts in cents (multiply euros × 100). But GET /checkout/v2/transactions/{id} (verify) returns amounts in euros.
The verification code uses:
Math.abs(tx.amount - expectedAmountInEuros) > 0.01;
Using cents here would break verification (amounts would differ by 100×).
Access Token Caching
PaymentsService.getAccessToken() caches the OAuth2 token for 25 seconds (Viva tokens last ~30 seconds). This avoids an OAuth2 roundtrip on every payment operation while ensuring the token doesn't expire between cache population and use.
Webhook (Async Backup Path)
Viva also POSTs payment events to POST /payments/webhook. This is an async backup — the primary verification path is the frontend redirect flow.
Signature Verification
Every webhook must be verified before processing:
const expectedSig = createHmac("sha256", VIVA_WEBHOOK_KEY).update(body).digest("hex");
timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig));
The x-viva-signature header is verified using crypto.timingSafeEqual to prevent timing attacks.
Event handling
StatusId | Action |
|---|---|
'F' (Finalized) | Calls verifyPayment() — idempotent, safe to call multiple times |
'E' (Error/Failed) | Logs a status history entry |
| Other | Logs and ignores |
The webhook handler never throws — it always resolves with { received: true }. If it threw, Viva would retry the webhook and potentially process the payment multiple times. Errors are logged to Loki/GlitchTip.
URL Verification Handshake
GET /payments/webhook returns { Key: VIVA_WEBHOOK_KEY }. This is the one-time handshake Viva performs when a webhook URL is registered in the Viva dashboard. After completing the handshake in each environment, consider removing or protecting this endpoint.
verifyPayment() — Idempotency
PaymentsService.verifyPayment() is idempotent:
- Checks if
Order.paymentStatusis alreadypaid— if so, returns early (no-op) - Verifies with Viva API
- Only then updates the DB
This ensures that if both the webhook and the frontend verify hit concurrently, the payment is recorded exactly once.
Required Environment Variables
| Variable | Example (Demo) | Notes |
|---|---|---|
VIVA_CLIENT_ID | abc123... | From Viva developer account |
VIVA_CLIENT_SECRET | xyz789... | From Viva developer account |
VIVA_SOURCE_CODE | 1234 | 4-digit payment source code from Viva |
VIVA_BASE_URL | https://demo-api.vivapayments.com | API base — NOT the checkout page URL |
VIVA_AUTH_URL | https://demo-accounts.vivapayments.com | OAuth2 token base |
VIVA_WEBHOOK_KEY | ... | HMAC key; rotate via Viva API (see Secrets Rotation runbook) |
All 5 are validated at onModuleInit — the API will fail to start if any are missing.
Known Gotchas
- Wrong auth URL — Using
demo.vivapayments.comforVIVA_AUTH_URLhangs silently. Always usedemo-accounts.vivapayments.com. - Amount units — Create uses cents, verify uses euros. Getting this wrong causes verification failures.
merchantTrnsnot always set — Older Viva transactions may not havemerchantTrns.verifyPaymentfalls back to matching byvivaOrderCodein this case.- Webhook race condition — Both webhook and frontend verify can fire at the same time. The idempotency check in
verifyPayment()handles this. - Source code per environment — The
VIVA_SOURCE_CODEis environment-specific. The demo and production source codes are different.