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

# Shared Architecture

> The verification-first lifecycle, the nine-field webhook signature, the Nomba API surface, and the local tooling every plugin shares.

All three plugins implement one identical, validated Nomba protocol. This page documents the shared contract; the per-platform pages cover how each hooks it into WooCommerce, PrestaShop, and VirtueMart. Source of truth: `integrations/README.md` plus the `NombaClient` / `NombaSignature` classes in each plugin folder.

## The lifecycle

```mermaid theme={null}
flowchart TD
    START["Shopper checks out"] --> CREATE["Create checkout order<br/>POST {prefix}/order<br/>store orderReference + mode"]
    CREATE --> REDIRECT["Redirect to data.checkoutLink<br/>(Nomba hosted page)"]
    REDIRECT --> PAY["Shopper pays on Nomba"]
    PAY --> RETURN["Return-URL verify<br/>GET {prefix}/transaction"]
    RETURN --> ROK{"paid AND<br/>amount matches?"}
    ROK -->|"yes"| COMPLETE["Complete the order"]
    ROK -->|"no / not yet"| HOLD["Hold for the webhook<br/>to resolve"]
    PAY --> HOOK["Webhook payment_success<br/>(async, from Nomba)"]
    HOOK --> SIG{"nine-field HMAC valid<br/>AND timestamp fresh?"}
    SIG -->|"no"| REJECT["401, log warning"]
    SIG -->|"yes"| REVERIFY["Re-verify via<br/>GET {prefix}/transaction"]
    REVERIFY --> VOK{"verified amount +<br/>currency match?"}
    VOK -->|"yes"| COMPLETE
    VOK -->|"no"| HELD["Hold the order"]
    COMPLETE --> REFUND["Admin refund<br/>POST {prefix}/refund"]
```

The two independent confirmation paths, the shopper's return and the asynchronous webhook, both converge on the same rule: **grant value only against what the Nomba API reports**, never on a payload's say-so. Whichever arrives first completes the order; the other short-circuits as a duplicate.

## Verification-first, always

<Steps>
  <Step title="Create the order">
    `POST {prefix}/order` with `{ order: { amount, currency, orderReference, customerEmail, callbackUrl } }`. The plugin stores the `orderReference` and the mode (test or live) on the order, then redirects the shopper to `data.checkoutLink`. An optional sub-account goes in `order.accountId` to route funds.
  </Step>

  <Step title="Verify on return">
    On the return URL, the plugin calls `GET {prefix}/transaction?idType=...&id={orderReference}` and completes the order **only** when the API reports it paid (`data.success` true; `data.status == "SUCCESS"` on the single-transaction endpoint) with a matching amount. A mismatch or an unpaid result leaves the order held for the webhook. The return page never grants value on its own.
  </Step>

  <Step title="Re-verify on the webhook">
    Nomba POSTs a signed `payment_success` event. The plugin checks the nine-field HMAC signature and the timestamp freshness window first, rejecting with 401 on any failure. Only then does it call `GET {prefix}/transaction` again and cross-check the **verified** amount and currency, not the webhook payload, before completing (or holding) the order.
  </Step>

  <Step title="Handle the other events">
    `payment_failed` marks an unpaid order failed, and `payment_reversal` marks a paid order refunded. Both act on the signed event without an extra API call; the re-verification applies to `payment_success` only.
  </Step>

  <Step title="Refund from admin">
    `POST {prefix}/refund` with `transactionId` (and an optional `amount` for partials), using the mode the order was paid in.
  </Step>
</Steps>

## The nine-field webhook signature

The inbound Nomba signature is **not** an HMAC over the raw body. It is a Base64 HMAC-SHA256, keyed by the merchant's webhook signature key, over nine colon-joined fields, with the timestamp taken from the `nomba-timestamp` header and the signature read from `nomba-signature`:

```
event_type : requestId : merchant.userId : merchant.walletId :
transaction.transactionId : transaction.type : transaction.time :
transaction.responseCode : <header timestamp>
```

The verification is timing-safe (`hash_equals`) and enforces a **300-second** freshness/replay window (`TOLERANCE_SECONDS = 300`). A request missing the signature or timestamp header is rejected. This matches the shared `NombaSignature` class in each plugin, for example `woocommerce/includes/class-nomba-signature.php`:

```php theme={null}
$parts = array(
    self::field( $event, 'event_type' ),
    self::field( $event, 'requestId' ),
    self::field( $merchant, 'userId' ),
    self::field( $merchant, 'walletId' ),
    self::field( $transaction, 'transactionId' ),
    self::field( $transaction, 'type' ),
    self::field( $transaction, 'time' ),
    self::field( $transaction, 'responseCode' ),
    (string) $timestamp,
);

return base64_encode( hash_hmac( 'sha256', implode( ':', $parts ), $secret, true ) );
```

<Note>
  This is the same colon-delimited field-HMAC scheme Duro's own backend verifies for inbound Nomba webhooks. See the Duro [security model](/security/security-model#webhook-signing) for the platform-side implementation.
</Note>

## The Nomba API surface

Live base `https://api.nomba.com` and sandbox base `https://sandbox.nomba.com` both use the `/v1/checkout` prefix; only the host differs. Auth is `POST /v1/auth/token/issue` on both.

| Step            | Method and path                                           | Notes                                                                                                                                                |
| --------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Authenticate    | `POST /v1/auth/token/issue`                               | Body `grant_type=client_credentials`, `client_id`, `client_secret`; header `accountId`. Returns `data.access_token` (about 30 min). Cached per mode. |
| Create checkout | `POST {prefix}/order`                                     | Body `{ order: { amount, currency, orderReference, customerEmail, callbackUrl } }`. Redirect to `data.checkoutLink`.                                 |
| Verify          | `GET {prefix}/transaction?idType=...&id={orderReference}` | Paid when `data.success` is true. Refund id is `data.transactionDetails.paymentReference`.                                                           |
| Confirm         | Webhook `payment_success`                                 | Nine-field Base64 HMAC-SHA256 (see above), then API re-verify before granting value.                                                                 |
| Refund          | `POST {prefix}/refund`                                    | Body `transactionId` plus optional `amount` for partials.                                                                                            |

Also handled: `payment_failed` (order marked failed) and `payment_reversal` (paid order marked refunded).

<Warning>
  Use the **parent (business) Account ID** from the dashboard in the `accountId` header; a sub-account ID is rejected at authentication (HTTP 403). Set the optional sub-account only in the create-order body (`order.accountId`) to route funds.
</Warning>

**Credentials per merchant** (all from the Nomba dashboard): Client ID, Client secret (private key), Account ID, and webhook signature key. Each plugin stores these in its own settings screen. Sandbox test card: `5434621074252808`, any expiry/CVV/PIN, OTP `9999`.

## Error states

The plugins fail closed and surface every failure. From the WooCommerce plugin's error table (`woocommerce/README.md`), representative of all three:

| Failure                                       | Surface                               |
| --------------------------------------------- | ------------------------------------- |
| Bad credentials                               | Admin notice, checkout hidden, logged |
| Checkout / order create fails                 | Shopper notice, order note, log       |
| Invalid webhook signature                     | 401, log warning                      |
| Stale webhook timestamp                       | 401, log warning                      |
| Nomba API re-verify call fails                | 502, log error                        |
| Nomba reports the payment not yet completed   | 200 unverified, order untouched       |
| Amount or currency mismatch (verified values) | Order on hold, log error              |
| Refund without transaction id                 | Explanatory refund error in admin     |
| Refund rejected by Nomba                      | Refund error plus order note          |

## Local tooling

`integrations/tools/` holds everything needed to exercise a plugin without credentials, and is never shipped in a plugin zip.

* **`mock-nomba/`** is a local stand-in for the Nomba API (`server.php`) with endpoints mirroring Nomba (`/v1/auth/token/issue`, `/v1/checkout/order`, `/v1/checkout/transaction`, `/v1/checkout/refund`) plus a local `/pay/{reference}` checkout page and a test-only `POST /mock/seed` route that pre-creates a paid transaction for the re-verifying webhook path to confirm against. `send-webhook.php` signs and posts webhooks, with `--tamper` and `--timestamp` flags to force the 401 rejection cases.
* **`demo/woocommerce/`** and **`demo/prestashop/`** are Docker Compose stores that run a full local checkout, webhook, and refund loop against the mock server, no credentials needed. Point a plugin at the mock with a base-URL override constant (for example `NOMBA_WC_API_BASE`).
* **`sandbox-smoke/`** proves the same lifecycle against the **real** Nomba sandbox (token, checkout, card payment, verify, refund) using test keys from the Nomba dashboard.
* **`tests/`** holds the PHPUnit suites (88 Nomba-plugin tests) run against a stubbed HTTP layer.

Base URLs are overridable for local testing (`NOMBA_WC_API_BASE` / `NOMBA_API_BASE` constants, or platform filters), which is how the demo stores redirect the plugin at the mock server. Against the real sandbox there is no `/mock/seed`: the signer only completes an order if the referenced transaction is genuinely paid, because the webhook re-verifies through the real `{prefix}/transaction` endpoint before granting value.

## Build

```bash theme={null}
bash tools/build.sh
```

Zips land in `dist/`. The script stages each plugin (`build.sh` copies only the shipped files, no tests) and zips it: WooCommerce as `nomba-for-woocommerce/`, PrestaShop as a `nomba/` folder, Joomla with `nomba.php` + `nomba.xml` at the zip root as VirtueMart's installer expects, plus the parallel Duro packages.

Per-platform install, configure, transact, refund, and webhook detail:

<CardGroup cols={3}>
  <Card title="WooCommerce" icon="wordpress" href="/integrations/woocommerce" />

  <Card title="PrestaShop" icon="bag-shopping" href="/integrations/prestashop" />

  <Card title="Joomla / VirtueMart" icon="joomla" href="/integrations/joomla" />
</CardGroup>
