ElebneElebneDocs
Pay API

Webhooks & Events

Receive real-time notifications for payment lifecycle events. Learn the 6 payment webhook events, their payloads, and how to verify signatures.

Webhooks & Events

Elebne sends webhook events to your configured endpoint URL whenever a payment intent changes status. Use webhooks to trigger order fulfillment, send receipts, or update your database in real-time.

Always use webhooks for fulfillment

Do not rely solely on redirect URLs or polling. Webhooks are the most reliable way to know when a payment is confirmed.

Webhook envelope

Every webhook delivery follows the same envelope format:

{
  "id": "663f1a2b4c5d6e7f8a9b0c1d",
  "event": "payment.confirmed",
  "created_at": "2026-04-04T10:35:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "PAID",
    "paid_at": "2026-04-04T10:35:00.000Z",
    "payer_phone": "42 ** ** 78",
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 1500,
      "payer_ttc": 0,
      "tof": 250,
      "tte": 500,
      "total_revenue": 750
    }
  }
}

Envelope fields

FieldTypeDescription
idstringUnique delivery ID
eventstringEvent type (see below)
created_atstringISO 8601 timestamp
sandboxbooleantrue for test mode events
dataobjectEvent payload (varies by event)

Data payload fields

FieldTypeDescription
reference_numberstringPayment intent reference, e.g. PI-3XXXXXXXXXXXXXX
short_codestring6-character alphanumeric short code
amountintegerAmount in centimes (or amount paid for OPEN intents)
currencystringAlways MRU
statusstringCurrent intent status
paid_atstringISO 8601 payment timestamp (null if not paid)
payer_phonestringMasked phone: 42 ** ** 78 (null if not paid)
metadataobjectYour custom key-value pairs
commissionobjectFee breakdown (provider, payer, tax, revenue)

Payment events

payment.created

Fired when a new payment intent is created via the API.

When: Immediately after POST /dev/intents succeeds.

Recommended action: Log the intent. No fulfillment needed yet.

{
  "id": "663f1a2b4c5d6e7f8a9b0001",
  "event": "payment.created",
  "created_at": "2026-04-04T10:30:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "PENDING",
    "paid_at": null,
    "payer_phone": null,
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 0,
      "payer_ttc": 0,
      "tof": 0,
      "tte": 0,
      "total_revenue": 0
    }
  }
}

payment.confirmed

Fired when a customer successfully pays. This is the most important event -- use it to fulfill orders.

When: After the customer confirms payment with their Elebne wallet PIN.

Recommended action: Mark the order as paid, send a confirmation email/SMS, begin fulfillment.

{
  "id": "663f1a2b4c5d6e7f8a9b0002",
  "event": "payment.confirmed",
  "created_at": "2026-04-04T10:35:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "PAID",
    "paid_at": "2026-04-04T10:35:00.000Z",
    "payer_phone": "42 ** ** 78",
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 1500,
      "payer_ttc": 0,
      "tof": 250,
      "tte": 500,
      "total_revenue": 750
    }
  }
}

payment.cancelled

Fired when a merchant cancels a pending intent via the API.

When: After POST /dev/intents/{ref}/cancel succeeds.

Recommended action: Mark the order as cancelled, release reserved inventory.

{
  "id": "663f1a2b4c5d6e7f8a9b0003",
  "event": "payment.cancelled",
  "created_at": "2026-04-04T11:00:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "CANCELLED",
    "paid_at": null,
    "payer_phone": null,
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 0,
      "payer_ttc": 0,
      "tof": 0,
      "tte": 0,
      "total_revenue": 0
    }
  }
}

payment.expired

Fired when a pending intent reaches its 24-hour expiration.

When: 24 hours after creation if the intent is still PENDING.

Recommended action: Mark the order as expired, release reserved inventory, optionally notify the customer.

{
  "id": "663f1a2b4c5d6e7f8a9b0004",
  "event": "payment.expired",
  "created_at": "2026-04-05T10:30:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "CANCELLED",
    "paid_at": null,
    "payer_phone": null,
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 0,
      "payer_ttc": 0,
      "tof": 0,
      "tte": 0,
      "total_revenue": 0
    }
  }
}

payment.refunded

Fired when a refund (full or partial) is processed.

When: After POST /dev/intents/{ref}/refund succeeds.

Recommended action: Update order status, notify customer of refund, adjust inventory.

{
  "id": "663f1a2b4c5d6e7f8a9b0005",
  "event": "payment.refunded",
  "created_at": "2026-04-04T14:00:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "REFUNDED",
    "paid_at": "2026-04-04T10:35:00.000Z",
    "payer_phone": "42 ** ** 78",
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 900,
      "payer_ttc": 0,
      "tof": 150,
      "tte": 300,
      "total_revenue": 450
    }
  }
}

payment.failed

Fired when a payment attempt fails (e.g. insufficient customer balance, technical error).

When: After a payment attempt encounters an unrecoverable error.

Recommended action: Log the failure. The intent remains PENDING or moves to FAILED depending on the error. The customer may retry.

{
  "id": "663f1a2b4c5d6e7f8a9b0006",
  "event": "payment.failed",
  "created_at": "2026-04-04T10:36:00.000Z",
  "sandbox": true,
  "data": {
    "reference_number": "PI-3XXXXXXXXXXXXXX",
    "short_code": "A1B2C3",
    "amount": 50000,
    "currency": "MRU",
    "status": "FAILED",
    "paid_at": null,
    "payer_phone": null,
    "metadata": { "order_id": "1234" },
    "commission": {
      "provider_ttc": 0,
      "payer_ttc": 0,
      "tof": 0,
      "tte": 0,
      "total_revenue": 0
    }
  }
}

Verifying webhook signatures

Every webhook request includes two headers for signature verification:

HeaderExampleDescription
X-Elebne-Signaturesha256=a1b2c3...HMAC-SHA256 signature
X-Elebne-Timestamp1712234100Unix timestamp (seconds)

The signature is computed as: HMAC-SHA256(secret, "{timestamp}.{raw_body}").

import crypto from 'crypto';

function verifyWebhookSignature(rawBody, signature, timestamp, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const received = signature.replace('sha256=', '');

  // Timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(received, 'hex')
  );
}

// Express middleware example
app.post('/webhooks/elebne', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-elebne-signature'];
  const timestamp = req.headers['x-elebne-timestamp'];
  const rawBody = req.body.toString();

  if (!verifyWebhookSignature(rawBody, signature, timestamp, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Reject old timestamps (> 5 minutes) to prevent replay attacks
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).send('Timestamp too old');
  }

  const event = JSON.parse(rawBody);
  // Process event...

  res.status(200).send('OK');
});
import hmac
import hashlib
import time

def verify_webhook_signature(raw_body: str, signature: str, timestamp: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        f'{timestamp}.{raw_body}'.encode(),
        hashlib.sha256
    ).hexdigest()

    received = signature.replace('sha256=', '')

    return hmac.compare_digest(expected, received)

# Flask example
@app.route('/webhooks/elebne', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Elebne-Signature', '')
    timestamp = request.headers.get('X-Elebne-Timestamp', '')
    raw_body = request.get_data(as_text=True)

    if not verify_webhook_signature(raw_body, signature, timestamp, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    # Reject old timestamps (> 5 minutes)
    if abs(time.time() - int(timestamp)) > 300:
        return 'Timestamp too old', 401

    event = request.get_json()
    # Process event...

    return 'OK', 200

Use the raw request body

Compute the HMAC on the raw request body string, not a re-serialized JSON object. JSON serialization may change key ordering or whitespace.

Delivery and retries

Elebne retries failed deliveries with exponential backoff:

AttemptDelay
1Immediate
230 seconds
32 minutes
415 minutes
51 hour
64 hours
712 hours
824 hours

A delivery is considered successful when your endpoint returns an HTTP 2xx status code. After 8 failed attempts, the delivery is marked as DEAD. You can manually retry dead deliveries from the Commerce Developer dashboard.

Best practices

  1. Return 200 quickly. Process the event asynchronously (e.g. via a queue) and return 200 OK within 5 seconds to avoid timeouts.
  2. Handle duplicates. Use the id field or reference_number + event as a deduplication key. Elebne may retry a delivery even if your server returned 200 (network issues on the response path).
  3. Verify signatures. Always validate X-Elebne-Signature before processing events to prevent spoofed requests.
  4. Check the timestamp. Reject events with timestamps older than 5 minutes to prevent replay attacks.
  5. Use the sandbox field. Ignore sandbox events in production event handlers.

Next steps

Was this page helpful?

On this page