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

# Status Polling

> Follow a transaction to a final state without ever paying twice

## Overview

Discovery and payment are asynchronous, so polling is not an optimisation — it is the mechanism. Everything you need to know about a transaction is in its `status`, read from [Get Transaction by ID](/en/api-reference/bill-payment/check-by-id).

<Note>
  Bill Payment does not push notifications to you. Polling is how outcomes arrive, for both discoveries and payments.
</Note>

## The state machine

Every transition a transaction can make:

| From         | To           | Trigger                                                |
| ------------ | ------------ | ------------------------------------------------------ |
| —            | `PENDING`    | `POST /v3/bills/discover`                              |
| `PENDING`    | `READY`      | Discovery finished — `bills[]` present, possibly empty |
| `PENDING`    | `FAILED`     | Discovery could not complete                           |
| `READY`      | `PROCESSING` | `POST /v3/bills/pay`                                   |
| `PROCESSING` | `SUCCESS`    | Paid                                                   |
| `PROCESSING` | `FAILED`     | Declined; nothing was charged                          |
| `PROCESSING` | `REFUNDED`   | Charged, then returned in full                         |
| `PROCESSING` | `UNKNOWN`    | Outcome not confirmed                                  |
| `UNKNOWN`    | `SUCCESS`    | Confirmed paid                                         |
| `UNKNOWN`    | `REFUNDED`   | Confirmed not paid; money returned                     |

`SUCCESS`, `FAILED` and `REFUNDED` are final — a transaction never leaves them.

| Status       | Meaning                               | Keep polling?               | Money moved       |
| ------------ | ------------------------------------- | --------------------------- | ----------------- |
| `PENDING`    | Discovery accepted, not finished      | Yes                         | No                |
| `READY`      | Discovery finished; `bills[]` present | No — it is your turn to act | No                |
| `PROCESSING` | Payment in flight                     | Yes                         | Not yet confirmed |
| `SUCCESS`    | Paid                                  | No                          | Yes — debited     |
| `FAILED`     | Did not go through                    | No                          | No                |
| `REFUNDED`   | Charged, then returned in full        | No                          | Net zero          |
| `UNKNOWN`    | Outcome not confirmed; under review   | **Yes**                     | Not yet known     |

## Recommended settings

These are starting points, not guarantees. Measure your own traffic and adjust.

| Phase                  | Interval             | Give up after                                       |
| ---------------------- | -------------------- | --------------------------------------------------- |
| Discovery (`PENDING`)  | 2 s, growing to 10 s | 2 minutes                                           |
| Payment (`PROCESSING`) | 3 s, growing to 10 s | 5 minutes                                           |
| Review (`UNKNOWN`)     | 30 s                 | Do not give up — hand it to a slower background job |

<Warning>
  "Give up" means **stop the foreground poll**, not "decide the payment failed". A transaction you stopped watching still has a real outcome; move it to a background reconciliation job that keeps checking.
</Warning>

Grow the interval instead of hammering a fixed one. A payment that has not finished in three seconds will not finish faster because you asked again.

## A production-grade poller

The same shape in four languages: an initial interval, growth up to a ceiling, an overall deadline, and one branch per status.

<CodeGroup>
  ```bash cURL theme={null}
  #!/usr/bin/env bash
  # Poll one transaction until it reaches a final state.
  BASE="https://billapi.oneclickdz.com"
  TXN="68b2f4c1a7d3e9f204c81a55"
  INTERVAL=3
  DEADLINE=$(( $(date +%s) + 300 ))

  while [ "$(date +%s)" -lt "$DEADLINE" ]; do
    STATUS=$(curl -s "$BASE/v3/bills/transactions/$TXN" \
      -H "X-Access-Token: YOUR_API_KEY" \
      | grep -o '"status":"[A-Z_]*"' | head -1 | cut -d'"' -f4)

    echo "status=$STATUS"

    case "$STATUS" in
      SUCCESS|FAILED|REFUNDED) exit 0 ;;
      UNKNOWN)                 INTERVAL=30 ;;
      *)                       [ "$INTERVAL" -lt 10 ] && INTERVAL=$((INTERVAL + 2)) ;;
    esac

    sleep "$INTERVAL"
  done

  echo "still not final — hand over to background reconciliation"
  exit 1
  ```

  ```javascript Node.js theme={null}
  const BASE = "https://billapi.oneclickdz.com";
  const KEY = process.env.ONECLICKDZ_API_KEY;

  const FINAL = new Set(["SUCCESS", "FAILED", "REFUNDED"]);
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  async function getTransaction(transactionId) {
    const response = await fetch(`${BASE}/v3/bills/transactions/${transactionId}`, {
      headers: { "X-Access-Token": KEY },
    });

    const body = await response.json();

    // Transient — worth another attempt.
    if (["AUTH_UNAVAILABLE", "SERVICE_UNAVAILABLE"].includes(body?.error?.code)) {
      return null;
    }

    if (!body.success) {
      throw new Error(`${body.error.code}: ${body.error.message}`);
    }

    return body.data;
  }

  async function pollUntilFinal(transactionId, { timeoutMs = 300_000 } = {}) {
    const deadline = Date.now() + timeoutMs;
    let interval = 3000;

    while (Date.now() < deadline) {
      const transaction = await getTransaction(transactionId);

      if (transaction) {
        if (FINAL.has(transaction.status)) return transaction;

        // Under review — slow right down, but never stop.
        interval = transaction.status === "UNKNOWN" ? 30_000 : Math.min(interval + 2000, 10_000);
      }

      await sleep(interval);
    }

    // Not final yet. Hand over — never assume a failure.
    await queueForReconciliation(transactionId);
    return null;
  }
  ```

  ```python Python theme={null}
  import os
  import time
  import requests

  BASE = 'https://billapi.oneclickdz.com'
  KEY = os.getenv('ONECLICKDZ_API_KEY')

  FINAL = {'SUCCESS', 'FAILED', 'REFUNDED'}
  TRANSIENT = {'AUTH_UNAVAILABLE', 'SERVICE_UNAVAILABLE'}


  def get_transaction(transaction_id):
      response = requests.get(
          f'{BASE}/v3/bills/transactions/{transaction_id}',
          headers={'X-Access-Token': KEY}
      )

      body = response.json()

      # Transient — worth another attempt.
      if body.get('error', {}).get('code') in TRANSIENT:
          return None

      if not body['success']:
          raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")

      return body['data']


  def poll_until_final(transaction_id, timeout_s=300):
      deadline = time.monotonic() + timeout_s
      interval = 3.0

      while time.monotonic() < deadline:
          transaction = get_transaction(transaction_id)

          if transaction:
              if transaction['status'] in FINAL:
                  return transaction

              # Under review — slow right down, but never stop.
              interval = 30.0 if transaction['status'] == 'UNKNOWN' else min(interval + 2, 10)

          time.sleep(interval)

      # Not final yet. Hand over — never assume a failure.
      queue_for_reconciliation(transaction_id)
      return None
  ```

  ```php PHP theme={null}
  <?php
  const BASE = 'https://billapi.oneclickdz.com';
  const FINAL_STATUSES = ['SUCCESS', 'FAILED', 'REFUNDED'];
  const TRANSIENT = ['AUTH_UNAVAILABLE', 'SERVICE_UNAVAILABLE'];

  function getTransaction(string $transactionId): ?array
  {
      $ch = curl_init(BASE . "/v3/bills/transactions/$transactionId");
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
      curl_setopt($ch, CURLOPT_HTTPHEADER, [
          'X-Access-Token: ' . getenv('ONECLICKDZ_API_KEY')
      ]);

      $body = json_decode(curl_exec($ch), true);
      curl_close($ch);

      // Transient — worth another attempt.
      if (in_array($body['error']['code'] ?? '', TRANSIENT, true)) {
          return null;
      }

      if (!$body['success']) {
          throw new Exception($body['error']['code'] . ': ' . $body['error']['message']);
      }

      return $body['data'];
  }

  function pollUntilFinal(string $transactionId, int $timeoutSeconds = 300): ?array
  {
      $deadline = time() + $timeoutSeconds;
      $interval = 3;

      while (time() < $deadline) {
          $transaction = getTransaction($transactionId);

          if ($transaction !== null) {
              if (in_array($transaction['status'], FINAL_STATUSES, true)) {
                  return $transaction;
              }

              // Under review — slow right down, but never stop.
              $interval = $transaction['status'] === 'UNKNOWN'
                  ? 30
                  : min($interval + 2, 10);
          }

          sleep($interval);
      }

      // Not final yet. Hand over — never assume a failure.
      queueForReconciliation($transactionId);
      return null;
  }
  ?>
  ```
</CodeGroup>

## Acting on each status

One branch per status, and no default that assumes failure.

```javascript theme={null}
async function applyOutcome(order, transaction) {
  switch (transaction.status) {
    case "SUCCESS":
      // Money moved. Keep the proof.
      await db.orders.update(order.id, {
        state: "PAID",
        operationId: transaction.operationId,
        total: transaction.total,
      });
      await downloadReceipt(transaction.transactionId);
      await notifyCustomerPaid(order);
      return;

    case "FAILED":
      // Nothing was charged. Safe to release the customer's funds.
      await db.orders.update(order.id, {
        state: "FAILED",
        reason: transaction.error?.code,
      });
      await releaseCustomerFunds(order);
      return;

    case "REFUNDED":
      // Money moved and came back in full. Same customer outcome as FAILED.
      await db.orders.update(order.id, {
        state: "REFUNDED",
        reason: transaction.error?.code,
      });
      await releaseCustomerFunds(order);
      return;

    case "UNKNOWN":
      // Not an outcome. Keep watching, and keep the customer's money held.
      await db.orders.update(order.id, { state: "UNDER_REVIEW" });
      await queueForReconciliation(transaction.transactionId);
      return;

    case "PENDING":
    case "PROCESSING":
      // Still working. Nothing to do but poll.
      return;
  }
}
```

## Handling `UNKNOWN`

`UNKNOWN` means the outcome has not been confirmed. It is not a failure, and it is not a success. It resolves on its own to `SUCCESS` or `REFUNDED`.

<Warning>
  While a transaction is `UNKNOWN`:

  * **Never** refund your own customer.
  * **Never** resend the payment.
  * **Never** show "payment failed" in your interface.

  Doing any of these turns an uncertain payment into a certain loss — either you refund a bill that was actually paid, or you pay it twice.
</Warning>

What to do instead:

<Steps>
  <Step title="Hold the customer's funds">
    Keep the amount reserved on your side and show a neutral state — "payment being confirmed", not "failed".
  </Step>

  <Step title="Slow the poll right down">
    Move to a 30-second cadence, or to a background job that checks periodically. Frequent polling does not speed up a review.
  </Step>

  <Step title="Act only on the resolution">
    `SUCCESS` — settle and keep the receipt. `REFUNDED` — release the funds. Only then tell the customer.
  </Step>
</Steps>

## Handling `REFUNDED`

`REFUNDED` means the payment was charged and then returned in full. The customer-facing outcome is the same as `FAILED` — the bill is not paid — but your accounting differs: money left and came back, so both movements belong in your ledger.

`error.code` explains why the payment did not stick:

| `error.code`          | `error.message`                                         |
| --------------------- | ------------------------------------------------------- |
| `PAYMENT_DECLINED`    | The payment was declined by the bank or partner portal. |
| `PARTNER_UNAVAILABLE` | The partner service is temporarily unavailable.         |
| `INVALID_ACCOUNT`     | The provided account identifier is invalid.             |
| `BILL_ALREADY_PAID`   | This bill has already been paid.                        |

The same four codes appear on `FAILED`. Branch on `code`, never on `message`.

## Handling transient errors while polling

A poll that fails is not a transaction that failed.

| Response                  | Meaning                                    | What to do                                                             |
| ------------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
| `503 AUTH_UNAVAILABLE`    | We could not verify your key in time       | Wait for `Retry-After` (5 s), poll again                               |
| `503 SERVICE_UNAVAILABLE` | Planned maintenance                        | Wait for `Retry-After`, poll again                                     |
| `500 INTERNAL_ERROR`      | Something failed on our side               | Poll again; escalate with the `requestId` if it persists               |
| `404 NOT_FOUND`           | Not your transaction, or wrong environment | Check the identifier and the key — do not treat it as a failed payment |

<Note>
  Never let a failed poll change your order state. Only a real `status` from a successful read may do that.
</Note>

## What never to do

| Never                                  | Why                                    | Instead                                                    |
| -------------------------------------- | -------------------------------------- | ---------------------------------------------------------- |
| Resend `pay` after a timeout           | The first one may have gone through    | Read the transaction; only `READY` proves it did not start |
| Refund your customer on `UNKNOWN`      | It may resolve to `SUCCESS`            | Hold the funds and keep polling                            |
| Treat a poll error as a failed payment | The transaction is unaffected          | Retry the poll                                             |
| Poll a fixed 1-second interval forever | It costs both sides and helps nobody   | Grow the interval to a ceiling                             |
| Branch on `completedAt`                | It is set in states that are not final | Branch on `status`                                         |

## Best practices

<CardGroup cols={2}>
  <Card title="One poller, one transaction" icon="route">
    Follow a transaction by its identifier. Repeatedly listing transactions to find it is slower and heavier.
  </Card>

  <Card title="Grow the interval" icon="stopwatch">
    Start at a few seconds, grow to about ten. Slow to 30 seconds once a transaction is `UNKNOWN`.
  </Card>

  <Card title="Hand over, do not give up" icon="clock-rotate-left">
    When the foreground poll times out, queue the transaction for background reconciliation.
  </Card>

  <Card title="Log the requestId" icon="fingerprint">
    Every poll returns one. Keep the last one against your order — it is what support needs.
  </Card>
</CardGroup>

## Next step

<Card title="Step 5: Receipts and reconciliation" icon="scale-balanced" href="/en/bill-payment-guides/5-receipts-and-reconciliation">
  Store the proof of payment and reconcile your ledger daily
</Card>

## Related pages

<CardGroup cols={2}>
  <Card title="Get Transaction by ID" icon="id-card" href="/en/api-reference/bill-payment/check-by-id">
    The transaction object and the field matrix
  </Card>

  <Card title="Polling Strategies" icon="chart-line" href="/en/polling-strategies">
    Cross-product polling guidance
  </Card>

  <Card title="Paying Bills" icon="money-bill-transfer" href="/en/bill-payment-guides/3-paying-bills">
    What to do before the payment
  </Card>

  <Card title="Sandbox Testing" icon="flask" href="/en/bill-payment-guides/6-sandbox-testing">
    Reproduce `UNKNOWN` and `REFUNDED` on demand
  </Card>
</CardGroup>
