Handle payment results
After a customer completes a payment, your system needs to know what happened.
A payment result can affect the customer experience, the order status, fulfillment, emails, invoices and accounting. Because of that, your backend should handle payment results reliably and safely.
The important rule
Do not rely only on the customer returning to your website.
A customer may close the browser before returning. A redirect may fail. A mobile browser may lose the session.
Use a server-side payment result to update the order status in your system.
That also applies to client-side ePay.js callbacks such as transactionAccepted: use them for UI feedback, not as the final payment confirmation.
Customer redirects vs server-side results
Redirects and server-side results solve different problems.
| Method | Used for | Should update order status? |
|---|---|---|
| Success URL | Showing a success page to the customer | No, not alone |
| Failure URL | Showing a failure page to the customer | No, not alone |
| Notification URL | Updating your backend | Yes |
| Webhook | Updating your backend | Yes |
Use redirects for the customer experience.
Use a notification URL or webhook for your backend order status.
Recommended payment flow
A safe payment flow usually looks like this:
Create an order in your system
Set the order status to pending
Create a payment with ePay
Send the customer to payment
Receive the payment result server-side
Verify the payment result
Update the order status
Show the final status to the customer
Prepare your order
Use clear order states in your own system.
| State | Meaning |
|---|---|
pending | The order has been created, but payment is not completed |
paid | The payment has been completed successfully |
failed | The payment failed |
cancelled | The payment was cancelled or abandoned |
refunded | The payment has been refunded |
partially_refunded | Part of the payment has been refunded |
Your exact states may differ, but the important part is that an order is not marked as paid until your backend has confirmed the payment.
Create the order before sending the customer to payment and keep it in a pending state until your backend has processed the result.
const order = await createOrder({ reference: "ORDER-1001", amount: 10000, currency: "DKK", status: "pending",});When creating the payment, include a reference that connects the ePay payment to your internal order.
{ "reference": "ORDER-1001"}When you receive the payment result, use the reference or payment ID to find the correct order.
Notification URLs and webhooks
A notification URL is an endpoint in your backend where ePay can send the payment result.
Example:
https://example.com/api/epay/notificationYour notification endpoint should:
- Be publicly available
- Accept requests from ePay
- Find the correct order
- Verify the payment result
- Update the order status
- Handle duplicate notifications safely
- Return a successful response when processed
What you receive
When the payment is completed, ePay sends a webhook HTTP request with the payment data.
The top-level objects are:
| Name | Description |
|---|---|
sca | Information about strong customer authentication, for example whether 3DS or delegated authentication was used. |
session | The ePay session object, including the original session configuration and state. |
transaction | The ePay transaction object with the payment result, reference, amount, currency and payment method details. |
subscription | The subscription object when the payment is part of a subscription flow. |
acquirerAgreement | Acquirer-specific context such as acquirer name or MCC. |
In practice, transaction.id, transaction.state, transaction.reference, session.id, and your own reference or attributes are the most important values for matching the payment and deciding what to do next.
Example webhook payload:
{ "sca": { "rejected": false, "type": "3DS", "verification": "NONE" }, "session": { "id": "0192473a-e382-79a9-bfc2-65da88fe812f", "subscriptionId": "01929a94-5fce-7ccc-a7e4-7e9249133b39", "amount": 1000, "attributes": { "key1": "value1", "key2": "value2" }, "exemptions": ["TRA"], "createdAt": "2024-10-01T10:38:14.658688472+02:00", "currency": "DKK", "expiresAt": "2024-10-01T12:41:14.658688472+02:00", "instantCapture": "OFF", "maxAttempts": 10, "reportFailure": false, "dynamicAmount": false, "notificationUrl": "https://example.com/notification", "preAuthUrl": "https://example.com/pre-auth", "successUrl": "https://example.com/success", "failureUrl": "https://example.com/failure", "pointOfSaleId": "0192473a-e381-705c-b61c-fc2ac9624afc", "reference": "reference-1", "state": "COMPLETED", "textOnStatement": "The text", "scaMode": "SKIP", "timeout": 60 }, "transaction": { "id": "01924756-d1f6-7bc6-bb51-2b5f87b43925", "subscriptionId": "01929a94-5fce-7ccc-a7e4-7e9249133b39", "state": "SUCCESS", "errorCode": null, "createdAt": "2024-10-01T09:08:45.174774Z", "sessionId": "01924756-badd-71d4-be55-da367f434da4", "paymentMethodId": "01924756-d1f6-738d-8040-90d76cedf01f", "paymentMethodType": "CARD", "paymentMethodSubType": "Visa", "paymentMethodExpiry": "2050-01-01", "paymentMethodDisplayText": "40000000XXXX0003", "scaMode": "SKIP", "amount": 1000, "currency": "DKK", "customerId": "User159", "instantCapture": "OFF", "notificationUrl": "https://example.com/notification", "pointOfSaleId": "0192473a-e381-705c-b61c-fc2ac9624afc", "reference": "reference-1", "textOnStatement": "The text", "exemptions": ["TRA"], "attributes": { "key1": "value1", "key2": "value2" }, "clientIp": "1.2.3.4", "type": "PAYMENT" }, "subscription": { "id": "01929a94-5fce-7ccc-a7e4-7e9249133b39", "paymentMethodId": "01924756-d1f6-738d-8040-90d76cedf01f", "currency": "DKK", "customerId": "User159", "pointOfSaleId": "0192473a-e381-705c-b61c-fc2ac9624afc", "reference": "subscription-1", "state": "ACTIVE", "type": "SCHEDULED", "expiryDate": null, "interval": { "period": "MONTH", "frequency": 1 }, "createdAt": "2024-10-01T10:38:14.658688472+02:00" }, "acquirerAgreement": { "acquirer": "shift4", "mcc": "4514" }}Webhook authentication
Every notification or webhook request includes an Authorization header.
Validate that header before trusting the request body.
- Compare the incoming
Authorizationheader with the value configured for the relevant Point of Sale or webhook - Treat the full header value as the secret, including the auth scheme such as
BearerorBasic - Do not assume the scheme is always
Bearer - If the authorization does not match, reject the request and do not process the payment result
By default, ePay generates a random Bearer token when the account is created. The token and the authorization scheme can be changed in ePay Backoffice.
Example:
const authorization = request.headers.get("authorization");if (authorization !== process.env.EPAY_WEBHOOK_AUTHORIZATION) { return new Response("Unauthorized", { status: 401 });}If you are using a Point of Sale notification URL, the authorization value is configured on the Point of Sale in ePay Backoffice.
Acknowledge quickly
ePay expects a standard HTTP 200 OK response when the notification has been received successfully.
The webhook request has a strict timeout of 5 seconds.
That means your endpoint should do only the minimum necessary work synchronously:
- Validate the authorization header
- Parse and validate the payload
- Store or enqueue the event for processing
- Return
200 OKquickly
Long-running business logic such as fulfillment, invoicing, email sending, ERP sync, or other external integrations should happen after the webhook has been acknowledged.
Merchants risk being moved to the low-priority queue if the webhook endpoint does not respond quickly enough.
Retry behavior
If ePay does not receive 200 OK, the webhook is retried automatically with exponential backoff.
| Attempt | Delay |
|---|---|
| 1 | 0 seconds |
| 2 | 0 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| 5 | 16 seconds |
| ... | Exponential backoff |
| 14+ | Capped at 3 hours |
| 25 | Final attempt |
Retries stop after 25 attempts.
Example handler
This example shows the basic idea.
Your real implementation should match your backend, order system and the actual ePay payload.
export async function handlePaymentNotification(payload) { const payment = await verifyPaymentResult(payload); const order = await findOrderByReference(payment.reference); if (!order) { throw new Error("Order not found"); } if (order.status === "paid") { return { ok: true }; } if (payment.status === "accepted") { await markOrderAsPaid(order.id); } if (payment.status === "declined") { await markOrderAsFailed(order.id); } return { ok: true };}Handle duplicate notifications
Your notification handler should be idempotent.
That means it should be safe to process the same payment result more than once.
This is important because notifications may be retried.
GuideUsing the Idempotency-Key HeaderLearn how to safely retry requests without creating duplicate payments or operations by using the Idempotency-Key header to ensure consistent, reliable API behavior.Recommended approach:
Store the payment ID or transaction ID
Check whether it has already been processed
If it has already been processed, return success
If it has not been processed, update the order
Store that the payment result has been processed
Example:
if (await hasProcessedPayment(payment.id)) { return { ok: true };}await processPayment(payment);await markPaymentAsProcessed(payment.id);return { ok: true };Verify and handle API errors
If your backend calls the API while handling a payment result, for example to verify a session or fetch the latest transaction state, you should log and handle API errors explicitly.
There are two common response formats:
Validation errors (422)
{ "errorCode": "VALIDATION_ERROR", "message": "Input validation errors", "errors": { "amount": [ "[required]: Is a required non-nullable field", "[int]: Must be an integer", "[min:0]: Must be greater than 0", "[max:999999999]: Must be less than 999999999" ] }}Other API errors (4XX and 5XX)
{ "errorCode": "INVALID_OR_EXPIRED_SESSION", "message": "Invalid session authentication or the session has expired."}In this flow, use errorCode for programmatic handling, and use message plus the request context for logs and debugging.
- Log the HTTP status code,
errorCode, order reference, transaction or session id, and endpoint called - Do not use the human-readable
messageas control flow - Treat
422as an integration or request-shape problem - Treat other
4XXand5XXresponses as operational failures and retry only when that makes sense for the specific endpoint
Handle different payment outcomes
Successful payments
When a payment is successful:
Verify the payment result
Find the order
Confirm that the amount and currency match the order
Mark the order as paid
Continue with fulfillment, email confirmation or invoice creation
Always check that the amount and currency are what you expect.
Failed payments
A failed payment should not trigger fulfillment.
Find the order
Keep the order unpaid
Mark the payment attempt as failed, if relevant
Let the customer try again, if your flow supports it
Abandoned payments
A customer may abandon a payment before completion.
Your system should usually keep the order as pending until it expires or is cancelled.
- Show the order as pending
- Let the customer retry the payment
- Expire the order after a set time
- Clean up abandoned orders automatically
Customer pages
Your success and failure pages are part of the customer experience, not the source of truth for your backend.
For success pages:
- Show a final success state only if your backend has already confirmed the payment
- Otherwise show a neutral confirmation message
- Do not promise that the order is paid before your backend has processed the payment result
Example:
Your payment has been received and is being confirmed.For failure pages:
- Show a clear message
- Let the customer try again
- Include a link back to checkout
- Show contact information if the problem continues
Example:
The payment could not be completed. Please try again or use another payment method.Logging and common problems
Add useful logs while building and testing.
Log:
- Incoming payment result timestamp
- Payment ID
- Reference
- Order ID
- Payment status
- Processing result
- Errors
Do not log sensitive payment data.
Common problems to check first:
- The notification URL is correct and publicly available
- The backend logs incoming notifications
- The system can match the payment to the correct order
- Duplicate notifications do not re-run side effects
- Only successful server-side results mark the order as paid
What you built
You now have a safer payment result flow.
Your system can receive payment results, update order statuses and avoid common issues like duplicate processing or relying only on redirects.