> ## Documentation Index
> Fetch the complete documentation index at: https://developers.ligdicash.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Common errors and solutions

> The most common errors merchants run into when integrating LigdiCash, their real causes, and how to fix them.

This page lists the most frequent errors seen during integration and production, based on real-world feedback from LigdiCash merchants.

<Note>
  Sub-codes (`Echec (CodeXX)`) are specific to each endpoint. See [the sub-codes reference](/en/errors/sub-codes) for the full mapping. To decode a sub-code in production, use the `wiki` field returned in the response.
</Note>

***

## Authentication failure

**Symptom**: `response_code: "01"`, `response_text: "Echec (Code00)"` on every request.

**Likely causes**:

* The `Apikey` header is missing or wrong
* The `Authorization` header does not follow the `Bearer {AUTH_TOKEN}` format
* The authorization token has expired or been revoked
* The keys you are using belong to a different API project

**Solution**:

```bash cURL theme={null}
curl -X POST https://app.ligdicash.com/pay/v01/... \
  -H "Apikey: {API_KEY}" \
  -H "Authorization: Bearer {AUTH_TOKEN}" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json"
```

Check your keys in the [LigdiCash dashboard](https://client.ligdicash.com) under the relevant API project. If the issue persists, contact support.

***

## Payin or payout not enabled on the account

**Symptom**: `response_code: "01"`, `response_text: "Echec (Code01)"` right at transaction creation.

**Cause**: The payin or payout feature is not enabled on your LigdiCash API project.

**Solution**:

Contact the LigdiCash team to enable the relevant feature: [developper@ligdicash.com](mailto:developper@ligdicash.com) or via your Partner Manager. Provide your API project identifier.

***

## Wrong payout route

**Symptom**: `response_code: "01"`, `response_text: "Echec (Code02)"` on the `withdrawal/create` endpoint, with description *"Customer not registered on the platform"*.

**Cause**: The `POST /pay/v01/withdrawal/create` endpoint performs a payout to the customer's **LigdiCash wallet**. It therefore requires the customer to have an account on the LigdiCash platform. If you want to send funds directly to a mobile money number without a pre-existing LigdiCash account, you must use the other endpoint.

<Warning>
  LigdiCash offers two payout routes with distinct uses:

  | Endpoint                          | Destination                  | Requirement                                |
  | --------------------------------- | ---------------------------- | ------------------------------------------ |
  | `POST /pay/v01/withdrawal/create` | Customer's LigdiCash wallet  | The customer must have a LigdiCash account |
  | `POST /pay/v01/straight/payout`   | Mobile money number directly | No LigdiCash account required              |

  Using `withdrawal/create` for a customer without a LigdiCash account systematically returns `Echec (Code02)`.
</Warning>

See [Payout to LigdiCash wallet](/en/payment-api/payout/to-ligdicash-wallet) and [Payout to mobile money](/en/payment-api/payout/to-mobile-money) to pick the right route for your use case.

***

## IP not whitelisted

**Symptom**: `response_code: "01"`, `response_text: "Echec (Code03)"` (createInvoice) or `"Echec (Code06)"` (createWithdrawal / createStraightWithdrawal) — *"IP Denied"*.

**Cause**: Payout endpoints require the calling server's IP address to be whitelisted on your LigdiCash project. If your server uses a dynamic IP (one that changes on every restart or reconnection), you will be blocked as soon as the IP changes.

**Important constraint**: LigdiCash limits the number of whitelisted IPs to **3 addresses maximum** per project.

**Solution**:

1. Identify the static IP address of your production server.
2. Send it to LigdiCash technical support ([developper@ligdicash.com](mailto:developper@ligdicash.com)) so they can add it to your project's whitelist — this cannot be done from the merchant dashboard.
3. If your infrastructure uses dynamic IPs (shared hosting, VPS without static IP, CI/CD), you must either:
   * Route all calls to LigdiCash through an **outbound proxy with a static IP**
   * Migrate to hosting with a dedicated static IP

<Note>
  The 3-IP cap matters if you have multiple environments (development, staging, production). Reserve the 3 slots for your production servers and test in staging from the same IP if possible. Anticipate this request before going live — the configuration delay depends on LigdiCash support.
</Note>

***

## Merchant balance too low on a specific operator

**Symptom**: `response_code: "01"`, `response_text: "Echec (Code04)"` (createWithdrawal) or `"Echec (Code08)"` (createStraightWithdrawal) — *"Merchant balance low"* or *"Merchant operator account low balance"*.

**Cause**: Your LigdiCash merchant account holds a separate balance **per operator**. A payout to Orange Money Burkina debits your Orange Burkina balance, not your global balance. If that operator-level balance is too low, the payout fails even when your global balance is positive.

**Solution**:

Top up the balance for the relevant operator via the [LigdiCash dashboard](https://client.ligdicash.com). Monitor balances per operator independently and set up low-balance alerts for every operator you use.

***

## Amount out of range

**Symptom**: `response_code: "01"`, `response_text: "Echec (Code02)"` on createInvoice — *"Wrong amount"*.

**Likely causes**:

* Amount below 9 CFA francs or above 2,000,000 CFA francs (LigdiCash global limits)
* Non-integer amount (`500.5` instead of `500` or `501`)
* Customer's daily mobile money limit reached on the operator side

<Warning>
  The CFA franc (XOF) has no sub-unit. The amount must always be an integer. `500.5` will be rejected.
</Warning>

Validate the amount on your server before calling the API:

```javascript Node.js theme={null}
function validateAmount(amount) {
  if (!Number.isInteger(amount)) throw new Error("Amount must be an integer");
  if (amount < 9)        throw new Error("Minimum amount: 9 CFA francs");
  if (amount > 2000000)  throw new Error("Maximum amount: 2,000,000 CFA francs");
}
```

***

## Transaction stuck in pending despite a valid OTP

**Symptom**: The transaction stays in `status: "pending"` indefinitely even though the customer says they generated the OTP correctly.

**Cause**: For operators in USSD OTP mode (Orange Money Burkina Faso, etc.), the customer generates an OTP from their USSD menu by entering the exact transaction amount. If you configured your integration so that **fees are charged to the customer**, the amount the customer must enter in the USSD is the base amount **plus the fees**. If the customer only enters the base amount, the OTP is valid but the expected amount does not match — LigdiCash cannot validate the transaction and leaves it in `pending`.

**Example**:

* Transaction amount: 10,000 CFA francs
* Fees charged to the customer: 200 CFA francs
* Amount to enter in the USSD: **10,200 CFA francs**
* If the customer enters 10,000 CFA francs → OTP valid but wrong amount → indefinite `pending`

**Solution**:

If fees are charged to the customer, your interface must clearly display the total amount to enter in the USSD, fees included. Communicate this amount in your on-screen instructions before the customer dials the USSD code.

```
⚠️ Dial the USSD code with the exact amount: 10,200 CFA francs (10,000 CFA francs + 200 CFA francs in fees)
```

If you absorb the fees on your side, the customer enters only the transaction amount and you take the cost in your margin.

***

## Misunderstanding the pending status

**Symptom**: You expect a callback with `status: "notcompleted"` after an invalid or expired OTP, but no callback arrives — the transaction stays in `pending` indefinitely.

**Cause**: LigdiCash does not automatically consider a transaction failed because the OTP was wrong. From LigdiCash's point of view, the transaction can still succeed if the customer provides the right OTP — the waiting window is undefined. LigdiCash only sends a `notcompleted` callback when the operator itself signals a definitive failure. Until that signal arrives, the transaction stays in `pending`, whether 5 minutes or several hours pass.

<Warning>
  Don't rely on LigdiCash to decide when a transaction is "expired". It is your system's responsibility to define a business timeout and act on it.
</Warning>

**Solution**:

Define a merchant-side timeout (for example 15 minutes after creation) past which you flip the transaction to `timeout` in your database and notify the customer to start over:

```javascript Node.js theme={null}
// Periodic check of pending transactions
async function checkPendingTransactions() {
  const timeout = new Date(Date.now() - 15 * 60 * 1000); // 15 minutes

  const pendingTransactions = await db.transactions.findAll({
    where: {
      status: "pending",
      created_at: { [Op.lt]: timeout },
    },
  });

  for (const tx of pendingTransactions) {
    // Last call to confirm before giving up
    const confirm = await callConfirm(tx.ligdicash_token);

    if (confirm.status === "completed") {
      await tx.update({ status: "completed" });
    } else {
      // Still pending after 15 min → merchant-side timeout
      await tx.update({ status: "timeout" });
      await notifyCustomer(tx, "Your payment did not go through. Please try again.");
    }
  }
}
```

***

## Double callback not deduplicated

**Symptom**: A single transaction triggers two actions in your system (double delivery, double credit, etc.).

**Cause**: LigdiCash systematically sends **two POST requests** for each callback event: one as `application/x-www-form-urlencoded` and one as `application/json`. This behavior is normal and documented. If your callback handler does not deduplicate, it will process both requests.

**Solution**:

Check your database to see if the transaction has already been processed before running any business logic:

```javascript Node.js theme={null}
async function handleCallback(payload) {
  const transactionId = payload.custom_data?.find(
    e => e.keyof_customdata === "transaction_id"
  )?.valueof_customdata;

  const transaction = await db.transactions.findOne({
    where: { transaction_id: transactionId }
  });

  if (transaction?.status === "completed") {
    return res.status(200).send("OK"); // Already processed
  }

  // ... business logic
}
```

See [Idempotency and deduplication](/en/payment-api/callback/idempotency) for the complete pattern.

***

## Callback received but transaction not found in database

**Symptom**: You receive a valid callback but your system can't find the matching transaction, or calling `confirm` returns `Echec (Code02) — Invoice not found`.

**Likely causes**:

* You're looking up the transaction with an identifier other than the `transaction_id` you stored at creation (internal ID, order number, callback token, etc.)
* You're using the `token` from the callback payload to call `confirm`, when you should use the `token` from creation

<Warning>
  Two different tokens flow through your integration:

  | Token              | Where                                   | Use                                       |
  | ------------------ | --------------------------------------- | ----------------------------------------- |
  | **Creation** token | Returned in `response_text` at creation | Store in your database, use for `confirm` |
  | **Callback** token | Present in the callback payload         | Never use to call `confirm`               |

  These two tokens are different and not interchangeable.
</Warning>

**Solution**:

Store the `transaction_id` you sent in `custom_data` at creation, and look up the transaction with that same `transaction_id` in the callback:

```javascript Node.js theme={null}
// On creation — store the transaction_id and the LigdiCash token
await db.transactions.create({
  transaction_id: "TXN-MY-INTERNAL-ID",  // your identifier
  ligdicash_token: response.token,         // LigdiCash creation token
  status: "pending",
});

// In the callback — look up by transaction_id, not by callback token
function extractTransactionId(callbackPayload) {
  const customData = callbackPayload.custom_data;
  if (!Array.isArray(customData)) return null;
  const entry = customData.find(e => e.keyof_customdata === "transaction_id");
  return entry?.valueof_customdata ?? null;
}

const transactionId = extractTransactionId(callbackPayload);
const transaction = await db.transactions.findOne({
  where: { transaction_id: transactionId }
});
```

See [transaction\_id pattern](/en/concepts/transaction-id-pattern) and [Callback security](/en/payment-api/callback/security) for the complete patterns.

***

## Related pages

* [The wiki field](/en/errors/wiki-field) — decode `Echec (CodeXX)` into a human-readable message
* [Sub-codes reference](/en/errors/sub-codes) — full list by endpoint
* [Idempotency and deduplication](/en/payment-api/callback/idempotency) — handle double callbacks
* [Callback security](/en/payment-api/callback/security) — re-verification via confirm
* [transaction\_id pattern](/en/concepts/transaction-id-pattern) — identify your transactions reliably
