# Configure and Verify Webhooks

Set up webhook endpoints to receive real-time notifications from Atlas for inflow and outflow events, verify webhook origins, handle duplicate events, and process payout state transitions.

Webhooks allow Atlas to notify your backend when something happens in your account (for example, a payment succeeds or a transfer settles). Instead of polling our API, you'll expose an HTTPS endpoint and Atlas will send a POST request with a JSON payload to that endpoint when a [supported event](https://docs.tryduplo.com/atlas/guides/webhooks#webhook-events) happens.

## [Enabling webhooks on Atlas](https://docs.tryduplo.com/en/atlas/guides/webhooks#enabling-webhooks-on-atlas)

To enable webhooks on Atlas:

1. Log into your Atlas dashboard.
2. Go to **Settings** -\> **Developers**.
3. Locate the **Webhook URL** field, click the " **Add Webhook URL button**", and enter your [webhook URL](https://docs.tryduplo.com/atlas/guides/webhooks#creating-a-webhook-endpoint) in the input field.

## [Webhook events](https://docs.tryduplo.com/en/atlas/guides/webhooks#webhook-events)

Webhook events from Atlas are scoped according to the feature triggering them.

### [Virtual account events](https://docs.tryduplo.com/en/atlas/guides/webhooks#virtual-account-events)

The following events are associated with virtual accounts:

- `IN_FLOW_SUCCESS_EVENT`: This event occurs when you receive an inflow from an Atlas virtual account. It applies to your Business virtual account and your customers' dedicated virtual accounts. This is a final state event, you will only receive it after the inflow has been completed and confirmed.

Below is an example payload for this event:

IN_FLOW_SUCCESS_EVENT

```
{
  "event_type": "IN_FLOW_SUCCESS_EVENT",
  "data": {
    "id": "019e6a54-26e3-7188-ba8b-1603009a026a",
    "customerAccountName": "John Doe",
    "customerAccountNumber": "7144857853",
    "customerBankName": "Wema Bank",
    "recipient": {
      "accountName": "Jane Doe",
      "accountNumber": "3769210226"
    },
    "sessionId": "586d23f6-3a21-4ff1-82f7-372bc23c30cd",
    "parentTransactionReference": null,
    "reference": "TRN_W3mZHUld59xM",
    "paymentChannel": "Bank Transfer",
    "source": "Wallet",
    "amount": {
      "value": 100090,
      "currency": "NGN",
      "formatted": "NGN 1,000.90"
    },
    "balance": {
      "value": 226031,
      "currency": "NGN",
      "formatted": "NGN 2,260.31"
    },
    "status": "Successful",
    "createdAt": "2026-05-27 16:45:00"
  }
}
```

### [Payment link & Checkout events](https://docs.tryduplo.com/en/atlas/guides/webhooks#payment-link--checkout-events)

The following events are associated with the checkout and payment link lifecycle:

- `CHECKOUT_COMPLETED`: The payment was successful.
- `CHECKOUT_FAILED`: The payment failed. This occurs if the customer cancels the transaction or if the transaction remains pending for too long (currently 15 minutes).

Below are examples of the payload for each event:

CHECKOUT_COMPLETED

CHECKOUT_FAILED

```
{
  "event_type": "CHECKOUT_COMPLETED",
  "data": {
    "checkoutReference": "CHK_123",
    "sourceReference": "SRC_456",
    "checkoutStatus": "completed",
    "isFinal": true,
    "businessId": "BUS_123",
    "customer": {
      "reference": "CUS_123",
      "email": "user@example.com",
      "name": "Jane Doe"
    },
    "checkoutAmount": {
      "value": 12000,
      "formatted": "NGN 12000",
      "currency": "NGN"
    },
    "transactionAmount": {
      "value": 10000,
      "formatted": "NGN 10000",
      "currency": "NGN"
    },
    "feeAmount": {
      "value": 200,
      "formatted": "NGN 200",
      "currency": "NGN"
    },
    "successfulTransactionReference": "TXN_789",
    "latestTransactionReference": "TXN_789",
    "paymentChannel": "bank_transfer",
    "senderName": "Jane Doe",
    "sessionId": "IJHBHJKOIJHJKO3245324RT4323R435GE",
    "processedAt": "2026-04-01T10:15:30Z",
    "completedAt": "2026-04-01T10:18:10Z",
    "failedAt": null,
    "cancelledAt": null,
    "reason": null,
    "transactionsSummary": {
      "total": 1,
      "successful": 1,
      "failed": 0,
      "pending": 0
    },
    "transactions": [
      {
        "transactionReference": "TXN_789",
        "status": "successful",
        "channel": "bank_transfer",
        "amount": {
          "value": 10000,
          "currency": "NGN",
          "formatted": "NGN 10,000.00"
        },
        "reason": null
      }
    ]
  }
}
```

### [Foreign exchange events](https://docs.tryduplo.com/en/atlas/guides/webhooks#foreign-exchange-events)

The following events are associated with the FX swap lifecycle:

- `FX_SWAP_CREATED`: This event occurs when an FX swap is initiated.
- `FX_SWAP_COMPLETED`: This event occurs when an FX swap is successfully completed.

Below are examples of the payload for each event:

FX_SWAP_CREATED

FX_SWAP_COMPLETED

```
{
  "event_type": "FX_SWAP_CREATED",
  "data": {
    "id": "019d970c-0af7-7134-a563-aa5ab4bbed62",
    "destinationCurrency": "USD",
    "sourceCurrency": "NGN",
    "debitedAmount": {
      "value": 27000,
      "currency": "NGN",
      "formatted": "NGN27000"
    },
    "creditedAmount": {
      "value": 900,
      "currency": "USD",
      "formatted": "USD900"
    },
    "status": "pending",
    "sourceReference": "b5b9075e-3618-4a85-98cb-239ef803f57f",
    "negotiatedRate": 0.00063,
    "createdAt": "2026-04-16T17:07:22+01:00"
  }
}
```

### [Payout events](https://docs.tryduplo.com/en/atlas/guides/webhooks#payout-events)

- `OUT_FLOW_PENDING_EVENT`: This event occurs when you initiate a payout.
- `OUT_FLOW_SUCCESS_EVENT`: This event occurs when your initiated payout succeeds.
- `OUT_FLOW_FAILED_EVENT`: This event occurs when your initiated payout fails.

Below are the examples of the payload for each event:

OUT_FLOW_PENDING_EVENT

OUT_FLOW_SUCCESS_EVENT

OUT_FLOW_FAILED_EVENT

```
{
  "event_type": "OUT_FLOW_PENDING_EVENT",
  "data": {
    "id": "0199e24d-e7d3-7054-860c-31934d517ee1",
    "recipientAccountName": "Test user",
    "recipientAccountNumber": "2018054387",
    "recipientBankName": "Kuda Microfinance Bank",
    "sessionId": null,
    "reference": "TRN_U5S95JEXZOON",
    "paymentChannel": "Bank Transfer",
    "amount": {
      "value": 20000,
      "currency": "NGN",
      "formatted": "NGN 200.00"
    },
    "balance": {
      "value": 4812000,
      "currency": "NGN",
      "formatted": "NGN 48,120.00"
    },
    "status": "Pending",
    "createdAt": "2025-10-14 10:39:00"
  }
}
```

## [Creating a webhook endpoint](https://docs.tryduplo.com/en/atlas/guides/webhooks#creating-a-webhook-endpoint)

A webhook endpoint is a POST HTTPS URL on your server that allows external services to deliver event data to your application in real time.

To work correctly, your webhook endpoint must:

- Verify the request origin to ensure the webhook is coming from a trusted source.
- Respond with a `200` OK status code to acknowledge successful receipt.
- Parse and process the event payload according to your application's logic.

Here are some examples of how to create a webhook endpoint in different programming languages and frameworks.

Node.js (Express)

Python (Flask)

Java (Spring Boot)

PHP (Laravel)

Go (net/http)

```
const express = require("express");
const app = express();
app.use(express.json());

app.post("/webhook-endpoint", (req, res) => {
  const event = req.body;

// Verify the origin of the webhook here

// Process the event data here

res.status(200).send("Webhook received");
});
```

## [Verifying the origin of an Atlas webhook](https://docs.tryduplo.com/en/atlas/guides/webhooks#verifying-the-origin-of-an-atlas-webhook)

Your webhook endpoint is public, meaning that anyone can send data to it, including malicious actors. How you verify a webhook depends on the event type.

### [Virtual account and payout events](https://docs.tryduplo.com/en/atlas/guides/webhooks#virtual-account-and-payout-events)

For `IN_FLOW_SUCCESS_EVENT`, `OUT_FLOW_PENDING_EVENT`, `OUT_FLOW_SUCCESS_EVENT`, and `OUT_FLOW_FAILED_EVENT`, verify the webhook using the transaction reference (`data.reference`) in the payload. Look it up against the Atlas transaction API. If the API returns a record, the webhook is from Atlas. If it doesn't, it is from another sender.

Node.js (Express)

Python (Flask)

Java (Spring Boot)

PHP (Laravel)

Go (net/http)

```
import fetch from "node-fetch";

async function verifyAtlasWebhook(reference) {
  const response = await fetch(
    `https://atlas.tryduplo.com/api/v1/transaction/find-by-reference/${reference}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.ATLAS_API_KEY}`,
      },
    },
  );

if (!response.ok) {
    return false;
  }

const result = await response.json();
  return Boolean(result?.data);
}
```

Usage inside your webhook endpoint:

Node.js (Express)

Python (Flask)

Java (Spring Boot)

PHP (Laravel)

Go (net/http)

```
const reference = req.body?.data?.reference;
const isValid = await verifyAtlasWebhook(reference);

if (!isValid) {
  return res.status(401).send("Invalid webhook source");
}
```

### [Checkout and payment link events](https://docs.tryduplo.com/en/atlas/guides/webhooks#checkout-and-payment-link-events)

For `CHECKOUT_COMPLETED` and `CHECKOUT_FAILED`, verify the webhook using the source reference (`data.sourceReference`) in the payload. Look it up against the [Get Checkout Transaction by Source Reference](https://docs.tryduplo.com/atlas/checkout/getCheckoutTransactionsBySourceReference) endpoint. If the API returns a record, the webhook is from Atlas.

Node.js (Express)

Python (Flask)

Java (Spring Boot)

PHP (Laravel)

Go (net/http)

```
import fetch from "node-fetch";

async function verifyCheckoutWebhook(sourceReference) {
  const response = await fetch(
    `https://atlas.tryduplo.com/api/v1/checkout/transactions/source-reference/${sourceReference}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.ATLAS_API_KEY}`,
      },
    },
  );

if (!response.ok) {
    return false;
  }

const result = await response.json();
  return Boolean(result?.data);
}
```

### [Handling duplicate events](https://docs.tryduplo.com/en/atlas/guides/webhooks#handling-duplicate-events)

Webhook events may be delivered more than once. Your webhook logic should be idempotent, meaning that processing the same event multiple times does not cause incorrect behavior. This is important because network failures can cause Atlas to retry webhook delivery and your endpoint might acknowledge a webhook but fail during processing. If you receive an event for a transaction that has already been processed, safely ignore it.

In some cases, multiple **valid** events may be sent for the same transaction reference. For example, a payout transitioning from `OUT_FLOW_PENDING_EVENT` to `OUT_FLOW_SUCCESS_EVENT`. Your integration should therefore distinguish between duplicate events (same reference and same event type) and valid state transitions (same reference, different event type).

You can detect duplicate events by keeping a record of all the webhook events processed by your system. Using these records, use the transaction reference (`data.reference`) as a unique identifier and track the last processed event type for each transaction.

Checkout events

Checkout events (`CHECKOUT_COMPLETED`, `CHECKOUT_FAILED`) do not have a `data.reference` field. Use `data.checkoutReference` as the unique identifier for deduplication instead.

Before processing an incoming event:

1. Extract the transaction reference and event type
2. Look up the last known event for the reference
3. If the event type has already been processed, ignore the event
4. If the event represents a valid state transition, process it and update the stored state

Once a transaction reaches a terminal state (for example, `OUT_FLOW_SUCCESS_EVENT` or `OUT_FLOW_FAILED_EVENT`), further events for that reference should typically be ignored.

For example:

Node.js (Express)

Python (Flask)

Java (Spring Boot)

PHP (Laravel)

Go (net/http)

```
const reference = event.data.reference;
const eventType = event.event_type;

const record = await db.transactions.findOne({ reference });

if (record) {
  // Ignore exact duplicate events
  if (record.lastEventType === eventType) {
    return res.status(200).send("Duplicate event ignored");
  }
}

// Process valid state transition
await processEvent(event);

// Update or create transaction record
await db.transactions.upsert({
  reference,
  lastEventType: eventType,
  updatedAt: new Date(),
});
```

## [Responding to the webhook](https://docs.tryduplo.com/en/atlas/guides/webhooks#responding-to-the-webhook)

Once the webhook has been verified and accepted, return a `200` OK response. Atlas has a **30** second wait time before retrying the webhook, so avoid long-running operations in the request lifecycle and process heavy logic asynchronously when possible.

If your server returns any status code other than `200` OK, Atlas will automatically retry the webhook a second and a third time. If the **3** attempts do not yield any `200` OK response, you'll have to contact support for your webhook to be retried manually.
