Skip to main content

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

EnvironmentAuth URLAPI URLCheckout URL
Demohttps://demo-accounts.vivapayments.comhttps://demo-api.vivapayments.comhttps://demo.vivapayments.com
Productionhttps://accounts.vivapayments.comhttps://api.vivapayments.comhttps://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 base
  • VIVA_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

StatusIdAction
'F' (Finalized)Calls verifyPayment() — idempotent, safe to call multiple times
'E' (Error/Failed)Logs a status history entry
OtherLogs 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:

  1. Checks if Order.paymentStatus is already paid — if so, returns early (no-op)
  2. Verifies with Viva API
  3. 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

VariableExample (Demo)Notes
VIVA_CLIENT_IDabc123...From Viva developer account
VIVA_CLIENT_SECRETxyz789...From Viva developer account
VIVA_SOURCE_CODE12344-digit payment source code from Viva
VIVA_BASE_URLhttps://demo-api.vivapayments.comAPI base — NOT the checkout page URL
VIVA_AUTH_URLhttps://demo-accounts.vivapayments.comOAuth2 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

  1. Wrong auth URL — Using demo.vivapayments.com for VIVA_AUTH_URL hangs silently. Always use demo-accounts.vivapayments.com.
  2. Amount units — Create uses cents, verify uses euros. Getting this wrong causes verification failures.
  3. merchantTrns not always set — Older Viva transactions may not have merchantTrns. verifyPayment falls back to matching by vivaOrderCode in this case.
  4. Webhook race condition — Both webhook and frontend verify can fire at the same time. The idempotency check in verifyPayment() handles this.
  5. Source code per environment — The VIVA_SOURCE_CODE is environment-specific. The demo and production source codes are different.