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

# Sandbox Testing

> Reproduce every outcome on demand, then go live with confidence

## Overview

Sandbox is where you prove your integration handles a decline, a refund and an unconfirmed payment — outcomes you cannot produce on demand with real money.

The account identifier you send **chooses the outcome**. Every scenario below is deterministic: the same identifier always produces the same result.

<Note>
  Sandbox uses the **same host, the same routes and the same key header** as production. The only thing that changes is the key. Call [Validate API Key](/en/api-reference/bill-payment/validate-key) and read `key.type` to confirm which environment you are in.
</Note>

## What is the same

Everything that matters to your code:

* the base URL, `https://billapi.oneclickdz.com`
* the `X-Access-Token` header
* all eight routes
* the response envelope, `requestId` and the `X-Request-Id` header
* the seven statuses and the asynchronous lifecycle
* polling, `ref` idempotency and the `403` / `409` guards
* the error codes and their HTTP statuses

Switching to production changes your key. It does not change a line of your integration.

## What differs

* A sandbox request never reaches a biller and never moves money.
* Outcomes are chosen by the account identifier, not by what an account really owes.
* `GET /v3/partners` returns a fixed map rather than live availability.
* Sandbox bills are returned with `fee: 0`, so `total` equals `amount`. Read `fee` and `total` from the response — in production they will not be zero.
* Transitions are fast: a discovery settles in well under a second, a payment in about half a second. The `UNKNOWN` scenario deliberately holds for about 60 seconds so you can exercise your review path.

<Warning>
  Sandbox is not a load test and not an availability test. A biller that is `ACTIVE` in the sandbox map may be down in production — handle `503 PARTNER_UNAVAILABLE` regardless of what sandbox told you.
</Warning>

## Business-flow scenarios

Send the identifier in the `account` object for the partner shown. "Discovery" is what the transaction reaches after `POST /v3/bills/discover`; "Payment" is what it reaches after `POST /v3/bills/pay`.

| Scenario                                   | Partner              | Account identifier                       | Discovery                                 | Payment                                   |
| ------------------------------------------ | -------------------- | ---------------------------------------- | ----------------------------------------- | ----------------------------------------- |
| Happy path — payable bill                  | `ADE`                | `reference: "0123456789012345678901234"` | `READY`, 1 bill @ 443.39 DZD              | `SUCCESS`                                 |
| Happy path — multi-bill                    | `SONELGAZ`           | `sonelgaz.invoice_number: "9876543210"`  | `READY`, 2 bills @ 1200.00 and 850.00 DZD | `SUCCESS`                                 |
| Bills found (payable)                      | `ADE`                | `reference: "0123456789012340000000001"` | `READY`, 1 bill @ 320.00 DZD              | `SUCCESS`                                 |
| No bills due                               | `ADE`                | `reference: "0123456789012340000000002"` | `READY`, `bills: []`                      | Nothing to pay — `404 NOT_FOUND`          |
| No bills due                               | `SEAAL`              | `reference: "0123456789012340000000000"` | `503 PARTNER_UNAVAILABLE` — see below     | —                                         |
| Below the 200 DZD floor (all filtered)     | `SONELGAZ`           | `sonelgaz.invoice_number: "0000000003"`  | `READY`, `bills: []`                      | Nothing to pay — `404 NOT_FOUND`          |
| Below the 200 DZD floor (150 DZD filtered) | `ADE`                | `reference: "0123456789012341111111111"` | `READY`, `bills: []`                      | Nothing to pay — `404 NOT_FOUND`          |
| Payment declined (no debit)                | `SONELGAZ`           | `sonelgaz.invoice_number: "4004004004"`  | `READY`, 1 bill @ 500.00 DZD              | `FAILED` + `PAYMENT_DECLINED`             |
| Payment declined                           | `ADE`                | `reference: "0123456789012340000000004"` | `READY`, 1 bill @ 400.00 DZD              | `FAILED` + `PAYMENT_DECLINED`             |
| Decline after debit — refund               | `ADE`                | `ade.sub_id: "000123456789"`             | `READY`, 1 bill @ 600.00 DZD              | `REFUNDED` + `PAYMENT_DECLINED`           |
| Late failure — refund                      | `ADE`                | `reference: "0123456789012340000000005"` | `READY`, 1 bill @ 550.00 DZD              | `REFUNDED` + `PAYMENT_DECLINED`           |
| Outcome unconfirmed — review, then refund  | `SONELGAZ`           | `sonelgaz.invoice_number: "6006006006"`  | `READY`, 1 bill @ 900.00 DZD              | `UNKNOWN` for about 60 s, then `REFUNDED` |
| Invalid account (malformed)                | `ADE`                | `reference: "abc0000000000000000000000"` | `400 INVALID_ACCOUNT`                     | —                                         |
| Invalid account (well-formed, nonexistent) | `SEAAL`              | `reference: "0123456789012340000000009"` | `503 PARTNER_UNAVAILABLE` — see below     | —                                         |
| Biller unreachable / timeout               | `ADE`                | `reference: "0123456789012345005005005"` | `503 PARTNER_UNAVAILABLE`                 | —                                         |
| Biller unavailable                         | `AADL`               | `aadlNumber: "1112223334"`               | `503 PARTNER_UNAVAILABLE`                 | —                                         |
| Already paid (24-hour guard)               | `ADE`                | `reference: "0123456789012347777777777"` | `409 BILL_ALREADY_PAID`                   | —                                         |
| Already paid                               | `ADE`                | `reference: "0123456789012340000000006"` | `409 BILL_ALREADY_PAID`                   | —                                         |
| Reused identifier                          | `Algérie Télécom`    | `phoneNumber: "023456789"`               | `READY`, 1 bill @ 300.00 DZD              | `SUCCESS`                                 |
| Any other well-formed identifier           | Any available biller | Anything not listed above                | `READY`, 1 bill @ 500.00 DZD              | `SUCCESS`                                 |

<Note>
  **The two SEAAL rows and the AADL row.** `SEAAL` and `AADL` are currently switched off in both environments, and that check runs before the sandbox scenario is chosen — so those three identifiers answer `503 PARTNER_UNAVAILABLE` rather than the outcome their scenario describes. They are listed here because they become reachable the moment those billers are switched back on. To test "no bills due" and "invalid account" today, use the `ADE` rows.
</Note>

The full object forms, for copying:

```json theme={null}
{
  "partner": "SONELGAZ",
  "account": {
    "sonelgaz": {
      "invoice_number": "9876543210",
      "amount_without_stamp": "15000",
      "ebb_key": "ABC123"
    }
  },
  "ref": "disc-sbx-multi-001"
}
```

```json theme={null}
{
  "partner": "ADE",
  "account": {
    "ade": {
      "sub_id": "000123456789",
      "period": "07/2026",
      "amount": "12000",
      "pay_key": "1234567"
    }
  },
  "ref": "disc-sbx-refund-001"
}
```

## Authentication and control scenarios

| Trigger                           | Response                   |
| --------------------------------- | -------------------------- |
| Omit the `X-Access-Token` header  | `401 MISSING_ACCESS_TOKEN` |
| Send an unknown key               | `401 INVALID_ACCESS_TOKEN` |
| Reuse a `ref` for the same biller | `403 DUPLICATED_REF`       |

<Warning>
  Exercise all three. The `403 DUPLICATED_REF` path in particular is the one your recovery logic depends on — if reusing a `ref` surprises your code in sandbox, it will surprise it in production with money attached.
</Warning>

## An end-to-end sandbox run

The ADE happy path, from discovery to receipt. Every value below is real and reproducible.

<CodeGroup>
  ```bash cURL theme={null}
  BASE="https://billapi.oneclickdz.com"
  KEY="YOUR_SANDBOX_API_KEY"

  # 0. Confirm the environment
  curl -s "$BASE/v3/validate" -H "X-Access-Token: $KEY"

  # 1. Discover
  TXN=$(curl -s "$BASE/v3/bills/discover" \
    -X POST \
    -H "Content-Type: application/json" \
    -H "X-Access-Token: $KEY" \
    -d '{
      "partner": "ADE",
      "account": { "reference": "0123456789012345678901234" },
      "ref": "disc-sbx-001"
    }' | grep -o '"transactionId":"[a-f0-9]*"' | cut -d'"' -f4)

  echo "transaction: $TXN"
  sleep 2

  # 2. Read the bills
  curl -s "$BASE/v3/bills/transactions/$TXN" -H "X-Access-Token: $KEY"

  # 3. Pay the first bill (note the NEW ref)
  curl -s "$BASE/v3/bills/pay" \
    -X POST \
    -H "Content-Type: application/json" \
    -H "X-Access-Token: $KEY" \
    -d "{
      \"transactionId\": \"$TXN\",
      \"billId\": \"sbx_bill_${TXN}_0\",
      \"ref\": \"pay-sbx-001\"
    }"

  sleep 3

  # 4. Confirm the outcome
  curl -s "$BASE/v3/bills/transactions/$TXN" -H "X-Access-Token: $KEY"

  # 5. Download the receipt
  curl -s "$BASE/v3/bills/transactions/$TXN/receipt" \
    -H "X-Access-Token: $KEY" \
    --fail --remote-header-name --remote-name
  ```

  ```javascript Node.js theme={null}
  import { writeFile } from "node:fs/promises";

  const BASE = "https://billapi.oneclickdz.com";
  const KEY = process.env.ONECLICKDZ_SANDBOX_API_KEY;
  const headers = { "X-Access-Token": KEY, "Content-Type": "application/json" };
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  async function call(path, options = {}) {
    const response = await fetch(`${BASE}${path}`, { headers, ...options });
    const body = await response.json();
    if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
    return body;
  }

  async function waitFor(transactionId, predicate, timeoutMs = 120_000) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      const { data } = await call(`/v3/bills/transactions/${transactionId}`);
      if (predicate(data)) return data;
      await sleep(2000);
    }
    throw new Error("Timed out");
  }

  // 0. Confirm the environment
  const { data: identity } = await call("/v3/validate");
  if (identity.key.type !== "SANDBOX") throw new Error("Not a sandbox key");

  // 1. Discover
  const { data: started } = await call("/v3/bills/discover", {
    method: "POST",
    body: JSON.stringify({
      partner: "ADE",
      account: { reference: "0123456789012345678901234" },
      ref: "disc-sbx-001",
    }),
  });

  // 2. Wait for the bills
  const ready = await waitFor(started.transactionId, (t) => t.status === "READY");
  console.log(ready.bills); // 1 bill @ 443.39 DZD

  // 3. Pay the first one, with a NEW ref
  await call("/v3/bills/pay", {
    method: "POST",
    body: JSON.stringify({
      transactionId: started.transactionId,
      billId: ready.bills[0].billId,
      ref: "pay-sbx-001",
    }),
  });

  // 4. Wait for a final state
  const final = await waitFor(started.transactionId, (t) =>
    ["SUCCESS", "FAILED", "REFUNDED"].includes(t.status),
  );
  console.log(final.status, final.operationId);

  // 5. Download the receipt
  const receipt = await fetch(
    `${BASE}/v3/bills/transactions/${started.transactionId}/receipt`,
    { headers: { "X-Access-Token": KEY } },
  );
  await writeFile("receipt-sbx.bin", Buffer.from(await receipt.arrayBuffer()));
  ```

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

  BASE = 'https://billapi.oneclickdz.com'
  KEY = os.getenv('ONECLICKDZ_SANDBOX_API_KEY')
  HEADERS = {'X-Access-Token': KEY, 'Content-Type': 'application/json'}


  def call(method, path, payload=None):
      response = requests.request(method, f'{BASE}{path}', headers=HEADERS, json=payload)
      body = response.json()
      if not body['success']:
          raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
      return body


  def wait_for(transaction_id, predicate, timeout_s=120):
      deadline = time.monotonic() + timeout_s
      while time.monotonic() < deadline:
          data = call('GET', f'/v3/bills/transactions/{transaction_id}')['data']
          if predicate(data):
              return data
          time.sleep(2)
      raise TimeoutError('Timed out')


  # 0. Confirm the environment
  identity = call('GET', '/v3/validate')['data']
  assert identity['key']['type'] == 'SANDBOX', 'Not a sandbox key'

  # 1. Discover
  started = call('POST', '/v3/bills/discover', {
      'partner': 'ADE',
      'account': {'reference': '0123456789012345678901234'},
      'ref': 'disc-sbx-001'
  })['data']

  # 2. Wait for the bills
  ready = wait_for(started['transactionId'], lambda t: t['status'] == 'READY')
  print(ready['bills'])  # 1 bill @ 443.39 DZD

  # 3. Pay the first one, with a NEW ref
  call('POST', '/v3/bills/pay', {
      'transactionId': started['transactionId'],
      'billId': ready['bills'][0]['billId'],
      'ref': 'pay-sbx-001'
  })

  # 4. Wait for a final state
  final = wait_for(
      started['transactionId'],
      lambda t: t['status'] in ('SUCCESS', 'FAILED', 'REFUNDED')
  )
  print(final['status'], final.get('operationId'))

  # 5. Download the receipt
  receipt = requests.get(
      f"{BASE}/v3/bills/transactions/{started['transactionId']}/receipt",
      headers={'X-Access-Token': KEY}
  )
  with open('receipt-sbx.bin', 'wb') as handle:
      handle.write(receipt.content)
  ```

  ```php PHP theme={null}
  <?php
  const BASE = 'https://billapi.oneclickdz.com';

  function call(string $method, string $path, ?array $payload = null): array
  {
      $ch = curl_init(BASE . $path);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
      curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
      curl_setopt($ch, CURLOPT_HTTPHEADER, [
          'Content-Type: application/json',
          'X-Access-Token: ' . getenv('ONECLICKDZ_SANDBOX_API_KEY')
      ]);
      if ($payload !== null) {
          curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
      }

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

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

      return $body;
  }

  function waitFor(string $transactionId, callable $predicate, int $timeoutSeconds = 120): array
  {
      $deadline = time() + $timeoutSeconds;
      while (time() < $deadline) {
          $data = call('GET', "/v3/bills/transactions/$transactionId")['data'];
          if ($predicate($data)) {
              return $data;
          }
          sleep(2);
      }
      throw new Exception('Timed out');
  }

  // 0. Confirm the environment
  $identity = call('GET', '/v3/validate')['data'];
  if ($identity['key']['type'] !== 'SANDBOX') {
      throw new Exception('Not a sandbox key');
  }

  // 1. Discover
  $started = call('POST', '/v3/bills/discover', [
      'partner' => 'ADE',
      'account' => ['reference' => '0123456789012345678901234'],
      'ref'     => 'disc-sbx-001'
  ])['data'];

  // 2. Wait for the bills
  $ready = waitFor($started['transactionId'], fn($t) => $t['status'] === 'READY');
  print_r($ready['bills']); // 1 bill @ 443.39 DZD

  // 3. Pay the first one, with a NEW ref
  call('POST', '/v3/bills/pay', [
      'transactionId' => $started['transactionId'],
      'billId'        => $ready['bills'][0]['billId'],
      'ref'           => 'pay-sbx-001'
  ]);

  // 4. Wait for a final state
  $final = waitFor(
      $started['transactionId'],
      fn($t) => in_array($t['status'], ['SUCCESS', 'FAILED', 'REFUNDED'], true)
  );
  echo $final['status'] . ' ' . ($final['operationId'] ?? '');

  // 5. Download the receipt
  $ch = curl_init(BASE . "/v3/bills/transactions/{$started['transactionId']}/receipt");
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'X-Access-Token: ' . getenv('ONECLICKDZ_SANDBOX_API_KEY')
  ]);
  file_put_contents('receipt-sbx.bin', curl_exec($ch));
  curl_close($ch);
  ?>
  ```
</CodeGroup>

<Note>
  Use a fresh `ref` on every run, or the second run answers `403 DUPLICATED_REF`. Suffixing the ref with your own test-run counter is the simplest approach.
</Note>

## Recommended integration validation

Three checks that prove the contract before you go further:

<Steps>
  <Step title="Verify the /v3/validate shape">
    `account.id`, `account.status`, `account.currency` and `key.type` are all present, and `key.type` is `SANDBOX`.
  </Step>

  <Step title="Confirm the /v3/partners map">
    Five keys, each with a `status` of `ACTIVE` or `UNAVAILABLE`, including `Algérie Télécom` with its accents.
  </Step>

  <Step title="Run one full round trip">
    Discovery, payment and a lookup by `ref` — proving that your `ref` resolves back to the transaction you created.
  </Step>
</Steps>

## Scenarios worth automating

Beyond the happy path, these four are the ones that catch real bugs:

| Test        | Identifier                                         | What it proves                                                                        |
| ----------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Empty bills | `ADE` `reference: "0123456789012340000000002"`     | You say "nothing payable", not "nothing owed", and you do not crash on an empty array |
| Decline     | `ADE` `reference: "0123456789012340000000004"`     | You release the customer's funds on `FAILED` and never charge them                    |
| Refund      | `ADE` `reference: "0123456789012340000000005"`     | You handle money that moved and came back                                             |
| Review      | `SONELGAZ` `sonelgaz.invoice_number: "6006006006"` | You hold on `UNKNOWN` for a minute without refunding or resending                     |

<Warning>
  The review scenario is the most important test in this table. It is the only cheap way to prove that your code does not refund a customer whose bill was actually paid.
</Warning>

## Going live

<Steps>
  <Step title="Swap the key, change nothing else">
    Same base URL, same header, same routes. Only the key value changes.
  </Step>

  <Step title="Assert the environment at start-up">
    Call `/v3/validate` and fail your boot sequence if `key.type` is not what that deployment expects.

    → [Validate API Key](/en/api-reference/bill-payment/validate-key)
  </Step>

  <Step title="Re-read fee and total from the response">
    Sandbox returns `fee: 0`. Production does not. If anything in your code assumed the fee was zero, it breaks here.
  </Step>

  <Step title="Confirm your poller handles UNKNOWN">
    In production this state is rare and expensive to get wrong. Prove the branch exists before you need it.
  </Step>

  <Step title="Check your reconciliation job runs">
    It should have been running against sandbox already, finding nothing. On the first production day it is your safety net.

    → [Receipts and reconciliation](/en/bill-payment-guides/5-receipts-and-reconciliation)
  </Step>

  <Step title="Keep the sandbox key">
    Every future change gets tested against these scenarios before it reaches production.
  </Step>
</Steps>

## Go-live checklist

| Check                                                            | Why                                                        |
| ---------------------------------------------------------------- | ---------------------------------------------------------- |
| `/v3/validate` returns `PRODUCTION` in the production deployment | Proves you shipped the right key                           |
| Keys live in a secret store, never in source control or logs     | A leaked production key moves real money                   |
| Every `ref` is derived from your own order identifier            | Recovery after a timeout depends on it                     |
| Discovery and payment use different `ref` values                 | Reusing one answers `403 DUPLICATED_REF`                   |
| Your order row is written before the payment is sent             | It is the only way to find a lost payment                  |
| `UNKNOWN` holds funds and never refunds                          | The single most expensive mistake to make                  |
| `403` and `409` are handled by looking up, never by retrying     | Retrying around a guard is how customers get charged twice |
| `fee` and `total` come from the response                         | Fees are configuration and can change                      |
| Receipts are downloaded and stored on success                    | Disputes arrive months later                               |
| Daily reconciliation runs and alerts                             | It catches everything the poller missed                    |

## Best practices

<CardGroup cols={2}>
  <Card title="Automate the four hard scenarios" icon="list-check">
    Empty bills, decline, refund and review. They are deterministic, so they belong in your test suite.
  </Card>

  <Card title="Never assume sandbox availability" icon="triangle-exclamation">
    The sandbox partner map is fixed. Production availability is live and changes.
  </Card>

  <Card title="Vary the ref per run" icon="fingerprint">
    Otherwise the second run of your test suite fails on `DUPLICATED_REF`.
  </Card>

  <Card title="Keep testing after go-live" icon="flask">
    Sandbox costs nothing. Run the suite on every release.
  </Card>
</CardGroup>

## Related pages

<CardGroup cols={2}>
  <Card title="Validate API Key" icon="key" href="/en/api-reference/bill-payment/validate-key">
    Prove which environment a key belongs to
  </Card>

  <Card title="Bill Payment Overview" icon="file-invoice-dollar" href="/en/bill-payment-guides/overview">
    The five-step map
  </Card>

  <Card title="Status Polling" icon="arrows-rotate" href="/en/bill-payment-guides/4-status-polling">
    Handling `UNKNOWN` and `REFUNDED`
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/en/api-reference/error-handling">
    Every error code in one place
  </Card>
</CardGroup>
