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
| Field | Type | Description |
|---|---|---|
id | string | Unique delivery ID |
event | string | Event type (see below) |
created_at | string | ISO 8601 timestamp |
sandbox | boolean | true for test mode events |
data | object | Event payload (varies by event) |
Data payload fields
| Field | Type | Description |
|---|---|---|
reference_number | string | Payment intent reference, e.g. PI-3XXXXXXXXXXXXXX |
short_code | string | 6-character alphanumeric short code |
amount | integer | Amount in centimes (or amount paid for OPEN intents) |
currency | string | Always MRU |
status | string | Current intent status |
paid_at | string | ISO 8601 payment timestamp (null if not paid) |
payer_phone | string | Masked phone: 42 ** ** 78 (null if not paid) |
metadata | object | Your custom key-value pairs |
commission | object | Fee 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:
| Header | Example | Description |
|---|---|---|
X-Elebne-Signature | sha256=a1b2c3... | HMAC-SHA256 signature |
X-Elebne-Timestamp | 1712234100 | Unix 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', 200Use 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
| 7 | 12 hours |
| 8 | 24 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
- Return 200 quickly. Process the event asynchronously (e.g. via a queue) and return
200 OKwithin 5 seconds to avoid timeouts. - Handle duplicates. Use the
idfield orreference_number+eventas a deduplication key. Elebne may retry a delivery even if your server returned 200 (network issues on the response path). - Verify signatures. Always validate
X-Elebne-Signaturebefore processing events to prevent spoofed requests. - Check the timestamp. Reject events with timestamps older than 5 minutes to prevent replay attacks.
- Use the
sandboxfield. Ignore sandbox events in production event handlers.
Next steps
- Payment Intents -- full intent lifecycle reference
- Refunds -- issue full and partial refunds
- Authentication -- API key types and scopes
Was this page helpful?