# Product integration

How a product (LoanBook, or the next app) starts a payment on the Optimize Solutions payment hub.

The hub is a Laravel application with MySQL. The HTTP API on this page is unchanged.

The product never talks to iPay. The product talks only to this hub.

| | Local | Live |
| --- | --- | --- |
| Hub | `http://localhost:4100` | `https://payment.optimizesolutions.lk` |

## Redirect URLs

Give these **to the hub**, in the checkout request. Do not put them in the iPay merchant portal.

| Field | Who receives it | Example |
| --- | --- | --- |
| `returnUrl` | Your product, after the customer finishes on iPay | `https://app.loanbook.lk/billing` |
| `cancelUrl` | Your product, if the customer cancels | `https://app.loanbook.lk/billing` |

The host must be listed in `config/products.json` under `allowedReturnHosts` for that product. `loanbook.lk` does not allow `evil.loanbook.lk` or a different port.

iPay itself is only given this hub:

- Browser success: `https://payment.optimizesolutions.lk/return?orderId=...`
- Browser cancel: `https://payment.optimizesolutions.lk/cancel?orderId=...`
- Server callback (set once in the iPay portal): `https://payment.optimizesolutions.lk/api/v1/ipay/callback`

After iPay sends the customer back, the hub **302-redirects** the browser to your `returnUrl` or `cancelUrl` and adds:

- `orderId`
- `status` — `PENDING`, `PAID`, `FAILED`, or `CANCELLED`
- `ipayReference` — on the return redirect, when iPay sent one

Example landing URL:

```text
https://app.loanbook.lk/billing?orderId=OSLB...&status=PAID&ipayReference=...
```

Do not trust the browser redirect alone. Activate the purchase from the webhook below, or from `GET /api/v1/payments/:orderId`.

## 1. Register the product

One object in `config/products.json`. The hub reloads this file when it changes.

```json
{
  "id": "loanbook",
  "name": "LoanBook",
  "orderPrefix": "OSLB",
  "apiKey": "change-me-loanbook-key",
  "webhookUrl": "https://app.loanbook.lk/api/v1/ipay/hub-webhook",
  "allowedReturnHosts": [
    "localhost:4000",
    "localhost:5173",
    "loanbook.lk",
    "www.loanbook.lk",
    "app.loanbook.lk"
  ]
}
```

- `id` is the `productId` you send
- `apiKey` is the `X-API-Key` header, and the secret used to sign webhooks back to you
- `webhookUrl` is where the hub tells you the payment result. Its host must be in `allowedReturnHosts`
- `orderPrefix` is stamped on hub-generated order ids (`OSLB…`)

Use a long random `apiKey` in production (`openssl rand -hex 24`).

## 2. Start a checkout

Your **backend** calls the hub. Do not call this from the browser. The API key must stay on the server.

```http
POST /api/v1/checkout
Content-Type: application/json
X-API-Key: <product apiKey>
```

```json
{
  "productId": "loanbook",
  "amount": 4999.00,
  "description": "LoanBook subscription",
  "customerName": "Jane",
  "customerEmail": "jane@example.com",
  "customerPhone": "0770000000",
  "returnUrl": "https://app.loanbook.lk/billing",
  "cancelUrl": "https://app.loanbook.lk/billing",
  "metadata": { "companyId": "demo-company" }
}
```

| Field | Required | Notes |
| --- | --- | --- |
| `productId` | yes | Must match the product that owns the API key |
| `amount` | yes | Greater than 0, at most 2 decimal places, max 10 characters once formatted (`4999.00`) |
| `returnUrl` | yes | Your page. `http` or `https`. Host must be allowed |
| `cancelUrl` | yes | Your page. Same rules as `returnUrl` |
| `orderId` | no | Your own id, alphanumeric, max 100 characters. If omitted, the hub generates `OSLB…` |
| `description` | no | Max 200 characters |
| `customerName` | no | Max 250 |
| `customerEmail` | no | Max 100 |
| `customerPhone` | no | Max 15 |
| `webhookUrl` | no | Overrides the product default for this payment only. Host must be allowed |
| `metadata` | no | JSON object, stored and sent back on the webhook. Max about 8 KB |

Local example:

```bash
curl -s -X POST http://localhost:4100/api/v1/checkout \
  -H "Content-Type: application/json" \
  -H "X-API-Key: change-me-loanbook-key" \
  -d '{
    "productId": "loanbook",
    "amount": 4999.00,
    "description": "LoanBook subscription",
    "customerName": "Jane",
    "customerEmail": "jane@example.com",
    "customerPhone": "0770000000",
    "returnUrl": "http://localhost:5173/billing",
    "cancelUrl": "http://localhost:5173/billing",
    "metadata": { "companyId": "demo-company" }
  }'
```

Response `200`:

```json
{
  "orderId": "OSLB...",
  "amount": "4999.00",
  "productId": "loanbook",
  "checkoutUrl": "https://sandbox.ipay.lk/ipg/checkout",
  "payUrl": "http://localhost:4100/pay/OSLB...",
  "fields": {
    "merchantWebToken": "...",
    "totalAmount": "4999.00",
    "orderId": "OSLB...",
    "orderDescription": "LoanBook subscription",
    "returnUrl": "http://localhost:4100/return?orderId=OSLB...",
    "cancelUrl": "http://localhost:4100/cancel?orderId=OSLB...",
    "customerName": "Jane",
    "customerEmail": "jane@example.com",
    "merchantParam1": "loanbook",
    "merchantParam2": "<internal payment id>",
    "checksum": "..."
  }
}
```

Send the customer's browser to **`payUrl`**. That page posts the iPay form. Do not build the iPay form in the product.

`fields.returnUrl` and `fields.cancelUrl` are the hub, not your site. That is correct.

Save `orderId` on your side, next to `metadata` (`companyId`, invoice id, and so on).

### Errors

| HTTP | Meaning |
| --- | --- |
| 401 | Missing or wrong `X-API-Key` |
| 403 | `productId` does not belong to that API key |
| 400 | Bad amount, bad URL, or host not allowed |
| 409 | `orderId` already exists |
| 503 | Hub has no iPay token or secret yet |

## 3. Receive the result

The hub `POST`s this JSON to your `webhookUrl` after iPay confirms the payment. It tries twice. Answer **HTTP 200**. Redirects are not followed.

```json
{
  "orderId": "OSLB...",
  "productId": "loanbook",
  "status": "PAID",
  "ipayStatus": "A",
  "amount": "4999.00",
  "ipayReference": "...",
  "metadata": { "companyId": "demo-company" }
}
```

| `status` | Meaning |
| --- | --- |
| `PAID` | Accepted (`ipayStatus` `A`) or pending settlement (`ipayStatus` `P`). Treat both as paid. `P` means the customer was charged and settlement finishes later |
| `FAILED` | Declined (`ipayStatus` `D`) |

Cancel in the browser sets `CANCELLED` and does not send this webhook.

Header `X-Payment-Signature` is hex HMAC-SHA256 of the **raw body**, using your product `apiKey`. Verify those exact bytes. Do not parse the JSON and stringify it again.

```js
const crypto = require("crypto");

function verifyPaymentWebhook(rawBody, signature, apiKey) {
  const expected = crypto.createHmac("sha256", apiKey).update(rawBody).digest("hex");
  const left = Buffer.from(expected);
  const right = Buffer.from(signature || "");
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}
```

Express needs the raw body. If you use `express.json()`, capture it first:

```js
app.post(
  "/api/v1/ipay/hub-webhook",
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf.toString("utf8");
    },
  }),
  (req, res) => {
    if (!verifyPaymentWebhook(req.rawBody, req.get("x-payment-signature"), process.env.PAYMENT_HUB_API_KEY)) {
      return res.status(401).json({ error: "Bad signature" });
    }
    const payment = JSON.parse(req.rawBody);
    // payment.status === "PAID" -> activate using payment.metadata
    // Same orderId may be delivered once. Ignore a second PAID.
    res.status(200).json({ ok: true });
  }
);
```

The same payment is not webhooked again after a successful delivery.

## 4. Optional status check

```http
GET /api/v1/payments/:orderId
X-API-Key: <product apiKey>
```

You only see your own product's payments. Another product's key gets `404`.

Use this if the customer returns with `status=PENDING` because the bank callback has not arrived yet. Poll until `PAID` or `FAILED`.

## What you do not send to iPay

- Merchant web token and secret stay on the hub
- Callback URL stays on the hub
- Card data stays on iPay

Your product sends the checkout JSON above, redirects the browser to `payUrl`, and handles the webhook.
