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 happens.

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 in the input field.

Activate webhooks on Atlas

Webhook events

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

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"
  }
}

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:

{
  "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

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:

{
  "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

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

{
  "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

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.

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

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

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.

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:

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

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

Important

This verification method assumes transaction references are not publicly exposed. If your application exposes references to end users, consider adding an additional verification layer (such as IP allowlisting) for higher security.

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 endpoint. If the API returns a record, the webhook is from Atlas.

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

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:

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

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.

How is this guide?

Last updated on

On this page