Merchant Integration Guide
Accept payments from any participating bank with a single integration. No per-bank setup.
Overview
1. Your backend creates a payment session
2. You redirect the customer (or embed the widget)
3. Customer authenticates with their bank via OTP
4. Gateway deducts funds and delivers a webhook to your endpoint
5. You fulfil the orderStep 1 — Get Your API Key
Contact your gateway operator (or your bank) to receive a Merchant API Key (mk_live_...).
All requests are authenticated with:
Authorization: Bearer mk_live_...Test Keys
Use mk_test_... keys during development. Test mode payments never touch real funds.
Step 2 — Create a Payment Session
From your backend (never from the browser — your API key stays server-side):
POST /api/v1/payments/sessions
Authorization: Bearer mk_live_...
Content-Type: application/json
{
"amount": 50000,
"currency": "LYD",
"destination": {
"type": "alias",
"value": "mtellesy"
},
"description": "Order #1042",
"reference": "order_1042",
"redirect_url": "https://mystore.com/orders/1042/result",
"webhook_url": "https://mystore.com/webhooks/openwave"
}Amount is in minor units
50000 = 500.00 LYD. See Amount Convention.
Response:
{
"session_id": "ops_01HZGV...",
"status": "PENDING",
"checkout_url": "https://gateway.example.com/pay/ops_01HZGV...",
"expires_at": "2026-04-24T05:00:00Z"
}Step 3 — Redirect the Customer
Send the customer to checkout_url. The gateway handles:
- Alias/IBAN entry
- Bank selection
- OTP authentication
- Redirect back to your
redirect_urlwith?session_id=...&status=completed
Or embed the widget (coming with the Web SDK):
<script src="https://js.openwave.ly/v1/openwave.js"></script>
<script>
openwave.checkout({
sessionId: 'ops_01HZGV...',
onSuccess: (e) => window.location.href = e.receipt_url,
onFailed: (e) => showError(e.message),
})
</script>Step 4 — Receive Webhooks
Configure a public HTTPS endpoint to receive events. Always verify the signature:
// Express.js example
app.post('/webhooks/openwave', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-openwave-signature']
const secret = process.env.WEBHOOK_SECRET
const expected = 'sha256=' + createHmac('sha256', secret)
.update(req.body)
.digest('hex')
if (sig !== expected) return res.status(401).send('Invalid signature')
const event = JSON.parse(req.body)
if (event.event === 'payment.completed') {
const { session_id, reference } = event.data
// fulfil order using reference
await fulfillOrder(reference)
}
res.json({ received: true })
})Payment Events
| Event | When |
|---|---|
payment.completed | Funds deducted, transfer confirmed ✅ |
payment.failed | OTP failure, timeout, or CBS error ❌ |
payment.expired | Session timed out before completion ⏱️ |
Step 5 — Verify (Optional Double-Check)
After receiving the webhook, you can verify the session status directly:
GET /api/v1/payments/sessions/{session_id}
Authorization: Bearer mk_live_...Destination Types
| Type | Example | Notes |
|---|---|---|
alias | mtellesy | NPT username, resolved to IBAN |
alias with bank | mtellesy@andalus | Specific bank routing |
iban | LY83002700100099900001 | Direct IBAN |
How Your Account Gets Credited
When a customer pays you, the money flows differently depending on whether you and the customer bank with the same institution:
| Scenario | What happens | Timing |
|---|---|---|
| Same bank | Internal CBS book transfer — instant debit and credit | < 1 second |
| Different banks | Customer's bank debits, sends via CBL LyPay to your bank, your bank credits | 2–10 seconds |
You only need to act on payment.completed — this webhook fires only after the credit at your bank is confirmed. Never fulfil an order on payment.initiated or payment.processing alone.
{
"event": "payment.completed",
"data": {
"session_id": "ops_01HZGV...",
"reference": "order_1042",
"settlement_type": "lypay",
"creditor_bank": "andalus"
}
}The settlement_type field is internal for same-bank payments and lypay for cross-bank. Your bank handles the actual credit — the gateway simply confirms it happened and fires this webhook.
Cross-bank payments are still real-time
LyPay is a real-time settlement rail. Cross-bank payments typically complete in under 10 seconds — you don't need to poll or wait.
Recurring Payments
Set up a mandate for recurring charges (subscriptions, instalments):
POST /recurring/mandates
{
"customer_alias": "mtellesy",
"amount": 5000,
"currency": "LYD",
"interval": "monthly",
"description": "Monthly subscription",
"start_date": "2026-05-01"
}Send the customer to the returned consent_url or open it in the official SDK/webview. The customer reviews the amount limit and frequency, then approves with bank OTP or push inside the hosted OpenWave surface. Do not collect OTPs in your merchant UI and do not call mandate approval endpoints from your backend.
Once active, you charge via:
POST /recurring/mandates/{mandate_id}/charge
{ "amount": 5000, "reference": "sub_may_2026" }API Key Security
- Never expose your API key in frontend code or mobile apps
- Store it as an environment variable:
OPENWAVE_API_KEY - Rotate your key immediately if you suspect it's compromised
- Use
mk_test_...keys in development and CI
Two-Tier Access Model
Merchant API keys cannot drive the checkout flow
resolve-payer, select-auth, and confirm are customer-facing endpoints. Any call to these with a merchant API key is rejected with 403 CHECKOUT_STEP_FORBIDDEN.
Your integration follows this split:
| What you do (server-side) | What the customer does (in browser) |
|---|---|
| Create session with merchant key | Enter alias/IBAN in the hosted page |
Redirect to checkout_url | Receive OTP, confirm payment |
| Poll status or wait for webhook | — |
This design ensures:
- Your server never sees the customer's IBAN or phone — that data lives in the browser session only.
- The customer controls the payment — you cannot trigger the debit from your backend after session creation.
- Standard compliance — follows SCA / PSD2 principles for delegated authentication.
Use the Official SDK
// ✅ Correct — merchant server creates the session
const session = await astro.payments.createSession({ amount, currency, destination })
// Then redirect: window.location.href = session.checkout_url
// ❌ Wrong — merchant cannot call checkout steps
await astro.payments.resolvePayer(sessionId, { payer_alias: '09...' }) // 403 ForbiddenThe checkout_url is the single handoff point: your server creates the session, the customer completes it.