# Payin test cases

> The payments to run before you go live on the Payments API, what each one delivers, and a notification handler that survives all of them.

Source: https://hub.ozow.com/integration-methods/apis/deprecated-integrations/payin-test-cases-payments-api/

> ⚠️ **The Payments API is deprecated.** This guide applies to you if your integration posts to
> `api.ozow.com` and builds a SHA512 hash. Your integration keeps working and remains supported. New
> payment methods and features are released on the One API only, and all new integrations use it,
> no new merchants are onboarded onto the Payments API. No end-of-life date has been set. We
> recommend planning a move when you next have development capacity. See [Migrating to One
> API](https://hub.ozow.com/integration-methods/apis/deprecated-integrations/migrating-to-one-api.md).

These test cases are recommended but not mandatory for go-live. Run at least the core ones before
you accept real payments. The optional ones apply only if you have built the feature they test.

They cover the Payments API, which is what the [embedded iframe](https://hub.ozow.com/integration-methods/apis/payin/embedded-iframe.md),
[embedded modal](https://hub.ozow.com/integration-methods/apis/payin/embedded-modal.md) and [embedded wallet](https://hub.ozow.com/integration-methods/apis/payin/embedded-wallet.md)
checkouts are built on as well as the legacy redirect. Building on One API? Use [payin test
cases](https://hub.ozow.com/integration-methods/testing/payin-test-cases-one-api.md); the statuses and the field names are different.

> ℹ️ **Testing environment**: Payin integrations can be tested directly in production. Any test
> transactions settle into your configured bank account. To test without moving real money, staging
> credentials are available on request: contact your account manager or
> [support@ozow.com](mailto:support@ozow.com).

## What arrives, and what it says

A Payments API payment tells you its outcome on the
[transaction notification](https://hub.ozow.com/api-reference/payments-api/webhooks/transaction-notification.md): a form encoded
`POST` to the `NotifyUrl` on the payment request, authenticated by its `Hash` field. Set no
`NotifyUrl` and nothing is delivered.

`Status` is the transaction's own status, not a mapped one: `Complete`, `Cancelled`, `Error`,
`Abandoned`, `PendingInvestigation` and the rest of the set on the [statuses
page](https://hub.ozow.com/integration-methods/statuses.md).

> ⚠️ **Important**: The notification is an unauthenticated `POST` to a URL anyone can call. The
> `Hash` is what makes it Ozow's. A handler that trusts the body without checking it can be told
> that any order is paid.

## A handler that passes every test below

```javascript
import crypto from "node:crypto";

// The fields, in this order. Empty ones are skipped.
const HASH_FIELDS = [
  "SiteCode",
  "TransactionId",
  "TransactionReference",
  "Amount",
  "Status",
  "Optional1",
  "Optional2",
  "Optional3",
  "Optional4",
  "Optional5",
  "CurrencyCode",
  "IsTest",
  "StatusMessage",
];

function isFromOzow(body) {
  const parts = HASH_FIELDS.map((field) => body[field] ?? "").filter(
    (value) => value !== "",
  );
  // Private key appended, then the whole string lowercased, then SHA512.
  const check = crypto
    .createHash("sha512")
    .update((parts.join("") + process.env.OZOW_PRIVATE_KEY).toLowerCase())
    .digest("hex");
  // Fixed-time compare: a plain === leaks the answer one character at a time.
  const sent = Buffer.from(String(body.Hash ?? "").toLowerCase());
  const ours = Buffer.from(check);
  return sent.length === ours.length && crypto.timingSafeEqual(sent, ours);
}

app.post(
  "/ozow/notify",
  express.urlencoded({ extended: false }),
  (req, res) => {
    // Test 4. Rejected, logged, and never acted on.
    if (!isFromOzow(req.body)) return res.status(400).send("invalid hash");

    res.sendStatus(200);

    const { TransactionId, Status, StatusMessage } = req.body;

    // Test 5. The same notification can arrive more than once.
    if (!claimOnce(TransactionId)) return;

    switch (Status) {
      case "Complete":
        fulfilOrder(TransactionId);
        break;
      case "Pending":
      case "PendingInvestigation":
        // Not an outcome. Leave the order alone.
        break;
      case "Cancelled":
      case "Error":
      case "Abandoned":
        failOrder(TransactionId, StatusMessage);
        break;
      default:
        // A value this code has never seen. Do not guess what it means.
        alertOps(`unknown status ${Status} on ${TransactionId}`);
    }
  },
);
```

`Amount` is hashed with two decimal places, exactly as it was sent. `claimOnce` is whatever makes
the update happen once in your system: a unique constraint on the transaction id, a row lock, or a
conditional write.

> ⚠️ **Important**: Update the order from this handler, never from the browser returning to your
> success URL, and never from an SDK event. A customer who closes the tab still has to end up with
> the right order state.

---

## Core test cases

### Test 1: Successful payment

Verify that your integration handles a completed payment end to end.

**Steps**

1. Create a payment request with
   [`POST /postpaymentrequest`](https://hub.ozow.com/api-reference/payments-api/post-post-payment-request.md), with `NotifyUrl` set
2. Complete the payment with a valid payment method
3. Verify that your endpoint receives the notification
4. Verify that the hash validates
5. Verify that the order is updated
6. Verify that the customer reaches your `SuccessUrl`

**Expected outcomes**

- `Status` is `Complete`
- Order credited and fulfilled once
- Customer redirected to `SuccessUrl`

---

### Test 2: Cancelled payment

Verify that a cancellation does not credit the order.

**Steps**

1. Create a payment request
2. Cancel the payment on the Ozow payment page
3. Verify that your endpoint receives the notification
4. Verify that the order is not credited
5. Verify that the customer reaches your `CancelUrl`

**Expected outcomes**

- `Status` is `Cancelled`
- Order not credited
- Customer redirected to `CancelUrl`

---

### Test 3: Failed payment

Verify that a failure does not credit the order.

**Steps**

1. Create a payment request
2. Attempt a payment that fails
3. Verify that your endpoint receives the notification
4. Verify that the order is not credited
5. Verify that the customer reaches your `ErrorUrl`

**Expected outcomes**

- `Status` is `Error`, with the detail in `StatusMessage`
- Order not credited
- Customer redirected to `ErrorUrl`

---

### Test 4: Hash verification

Verify that verification actually rejects something.

**Steps**

1. Complete a successful test payment and keep the notification body
2. Verify it with your implementation and confirm it passes
3. Change one field, `Amount` or `Status`, leaving `Hash` as it was, and replay it
4. Confirm the tampered notification fails verification and is not processed

**Expected outcomes**

- The genuine notification passes
- The tampered notification is rejected, logged, and updates nothing
- Empty fields are skipped, and the string is lowercased before hashing

---

### Test 5: The same notification twice

Verify that one payment updates one order once.

> ℹ️ **Note**: Ozow may send the same notification more than once. Your handler has to survive it.

**Steps**

1. Complete a successful test payment
2. Deliver the same notification to your endpoint a second time
3. Verify that your handler processes it idempotently

**Expected outcomes**

- The second notification is recognised and changes nothing
- The order is credited once
- Nothing throws

---

### Test 6: Transaction status check

Verify that you can ask, rather than wait.

**Steps**

1. Complete a test payment and note the transaction reference
2. Call
   [`GET /GetTransactionByReference`](https://hub.ozow.com/api-reference/payments-api/get-get-transaction-by-reference.md)
3. Compare the status with the outcome you were sent

**Expected outcomes**

- The call returns the transaction
- Its status matches the notification

---

## Optional test cases

Run these only if you have built the feature.

### Test 7: Standalone button

Verify that a button opens the payment method it names.

**Steps**

1. Build the standalone button with the correct `SelectedBankId`
2. Create a payment request with that field set
3. Verify that the Ozow payment page opens on that payment method

**Expected outcomes**

- The named payment method is shown
- The customer is routed to it without choosing again

---

### Test 8: Customer Identity Verification

Run this only if your business is in a high-risk industry and Customer Identity Verification is
required.

**Steps**

1. Create a payment request with a verified customer identity in `customerIdentifier`
2. Verify that the page offers only payment methods linked to that identity
3. Verify that unlinked payment methods are hidden
4. Create a payment request with no identity and verify which methods are offered

**Expected outcomes**

- Only linked payment methods are offered when an identity is passed
- The behaviour without an identity is what you expect for your account