Skip to content

Webhooks

Webhooks deliver real-time event notifications to your server when payment events occur.

Webhook Envelope

All events share the same structure:

json
{
  "event": "payment.completed",
  "api_version": "1.0.0",
  "timestamp": "2026-04-24T04:30:00Z",
  "data": {
    "session_id": "ops_01HZGV...",
    "reference": "order_1042",
    "amount": 50000,
    "currency": "LYD",
    "status": "COMPLETED"
  }
}

Signature Verification

Every delivery includes an X-OpenWave-Signature header:

X-OpenWave-Signature: sha256=<HMAC-SHA256(raw_body, webhook_secret)>

Always verify before processing the event. If signatures don't match, discard the request.

js
import { createHmac } from 'crypto'

function verifyWebhook(rawBody, signature, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(rawBody)  // rawBody must be Buffer, not parsed JSON
    .digest('hex')
  return signature === expected
}
python
import hmac, hashlib

def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)
php
function verify_webhook(string $raw_body, string $signature, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $raw_body, $secret);
    return hash_equals($expected, $signature);
}

Never compute the signature on parsed JSON

Use the raw request body as bytes. Parsing and re-serialising JSON can change whitespace and break the HMAC.

Responding to Webhooks

Return 2xx within 10 seconds. The gateway retries failed deliveries:

AttemptDelay
1Immediate
230 seconds
35 minutes
430 minutes
52 hours

After 5 failed attempts, the delivery is marked FAILED and visible in the admin dashboard for manual retry.

Idempotency

Your webhook handler must be idempotent — the same event may be delivered more than once (on retry). Use the session_id or event + timestamp combination to deduplicate:

js
const key = `${event.event}:${event.data.session_id}`
if (await redis.get(key)) return res.json({ received: true }) // already processed
await redis.set(key, '1', 'EX', 86400)
// process event...

Event Reference

Payment Events

EventTrigger
payment.completedFunds deducted and transfer confirmed ✅
payment.failedOTP failure, timeout, or CBS error ❌
payment.expiredSession TTL elapsed before completion ⏱️

Recurring Mandate Events

EventTrigger
mandate.activatedCustomer confirmed the recurring mandate
mandate.cancelledMandate cancelled by any party
mandate.charge.completedCharge executed successfully
mandate.charge.failedCharge attempt failed

Open Banking Events

EventTrigger
consent.grantedCustomer approved the TPP consent
consent.revokedRevoked by TPP, customer, or bank
consent.expiredConsent reached its expiry date
payment_order.completedPayment order executed successfully
payment_order.failedBank returned an error
payment_order.pending_scaBank requires SCA — redirect to sca_url
payment_order.rejectedBank declined the payment order

Configuring Webhook Endpoints

http
POST /merchants/{id}/webhooks
Authorization: Bearer mk_live_...

{
  "url": "https://mystore.com/webhooks/openwave",
  "events": ["payment.completed", "payment.failed"]
}

Omit events to subscribe to all events.

Webhook secrets are issued at configuration time. Rotate via:

http
POST /merchants/{id}/webhooks/{webhook_id}/rotate-secret