Looking for ePay classic docs? Go to docs.epay.dk
ePay documentationDocsePay documentation

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.

MethodUsed forShould update order status?
Success URLShowing a success page to the customerNo, not alone
Failure URLShowing a failure page to the customerNo, not alone
Notification URLUpdating your backendYes
WebhookUpdating your backendYes

Use redirects for the customer experience.

Use a notification URL or webhook for your backend order status.

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.

StateMeaning
pendingThe order has been created, but payment is not completed
paidThe payment has been completed successfully
failedThe payment failed
cancelledThe payment was cancelled or abandoned
refundedThe payment has been refunded
partially_refundedPart 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/notification

Your 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:

NameDescription
scaInformation about strong customer authentication, for example whether 3DS or delegated authentication was used.
sessionThe ePay session object, including the original session configuration and state.
transactionThe ePay transaction object with the payment result, reference, amount, currency and payment method details.
subscriptionThe subscription object when the payment is part of a subscription flow.
acquirerAgreementAcquirer-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 Authorization header 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 Bearer or Basic
  • 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 OK quickly

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.

AttemptDelay
10 seconds
20 seconds
34 seconds
48 seconds
516 seconds
...Exponential backoff
14+Capped at 3 hours
25Final 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 message as control flow
  • Treat 422 as an integration or request-shape problem
  • Treat other 4XX and 5XX responses as operational failures and retry only when that makes sense for the specific endpoint
GuideSee the full error code referenceUse the full guide when you need the broader list of payment and API error codes, including webhook `transaction.errorCode` values.

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.

Next steps

How is this guide?

On this page