Webhooks let Payline notify your system in real time when significant events happen on your account — a card payment completes, a payout finishes, a virtual account receives funds, and so on. Instead of polling our API for status, your server receives a signed HTTPS POST from Payline within seconds of the event. This guide covers everything you need to set up, receive, and verify Payline webhooks.
How Webhooks Work
- You register a webhook URL on the Payline dashboard along with the event categories you care about. We generate a unique
hash_keyand return it to you once — store it somewhere safe; you'll need it to verify signatures. - An event fires on your account (e.g. a card payment succeeds).
- Payline sends a
POSTrequest to your configured URL within seconds. The payload is signed with HMAC-SHA256 using yourhash_key. - Your server verifies the signature, responds with
2xx, and processes the event asynchronously. - If your server doesn't respond
2xx(timeout, network error, non-2xx status), Payline retries with exponential backoff up to 8 times over ~18 hours. After that, the webhook is dead-lettered and won't be retried automatically.
Configuring Webhooks
Webhook configuration is done from the Payline merchant dashboard under Settings → Webhooks. Configuration is per-business. You can only have one active webhook URL per business.
When you create a configuration, you provide:
| Field | Required | Description |
|---|---|---|
url | yes | The HTTPS URL Payline will POST events to |
subscribed_events | yes | Array of categories. Any of: payment, payout |
You can also toggle the configuration on or off, or rotate the signing key, at any time.
Thehash_keyis shown only once — at creation time and on rotation. Save it in a secrets manager. If you lose it, rotate to generate a new one (this invalidates the old key immediately).
Event types
Payline emits 5 event types. Card, virtual account, and any other payment method flow through payment.*; payouts through payout.*. The failure reason (card decline, VA mismatch, VA expired, etc.) is carried in the data.response_summary and data.response_code fields, not in the event name.
| Event | Fires when |
|---|---|
payment.success | A payment completed successfully — card payment, virtual account collection, etc. |
payment.failed | A payment terminally failed — card decline, VA amount mismatch, VA expiration, etc. Check data.response_summary for the reason. |
| `payment.mismatch | A dynamic virtual account payment mismatch |
payout.success | A payout completed successfully. |
payout.failed | A payout terminally failed. |
Subscribe only to the events you need. Events outside your subscribed_events list are silently skipped.
Webhook payload
Every delivery has the same envelope shape. The data field contains the full transaction object — identical to what you'd receive from our standard API endpoints, so you can reuse your existing parsers.
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Payline-Signature | Hex-encoded HMAC-SHA256 of the raw request body, signed with your hash_key. |
X-Payline-Idempotency-Key | The webhook delivery UUID. Use it to deduplicate retries (same UUID = same event). |
Body
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event": "payment.success",
"created_at": "2026-06-04T10:30:00Z",
"data": {
"request_type": "payment",
"id": "txn-abc-123",
"reference": "PAY-REF-001",
"status": "success",
"amount": 50000,
"fee": 250,
"currency": "NGN",
"response_code": "10000",
"response_summary": "Payment completed",
"payment_timestamp": "2026-06-04T10:29:58Z",
"source": {
"type": "card",
"card_type": "VISA",
"card_bin": "424242",
"card_last4": "4242",
"expiry_month": "12",
"expiry_year": "2028"
},
"customer": {
"name": "Jane Doe"
}
}Field reference
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Webhook delivery ID. Matches X-Payline-Idempotency-Key header. |
event_id | string (UUID) | Same UUID as id — the canonical identifier you should use for deduplication. |
event | string | One of payment.success, payment.failed, payment.mismatch, payout.success, payout.failed. |
created_at | string (RFC 3339) | When Payline created the delivery record. |
data | object | Transaction payload — same shape as the API response for that resource. |
data.status | string | Terminal status: success or failed. |
data.response_code | string | Internal code summarising the outcome — e.g., "10000" for success. |
data.response_summary | string | Human-readable explanation. Examples below. |
data.amount | number | Amount in minor unit (e.g., kobo for NGN). |
data.currency | string | ISO 4217 currency code. |
The exact shape of data varies by payment method:
- Card payments: includes
source(card details) andcustomer. - Virtual account payments: includes
virtual_account_details(account number, bank, expected vs received amount). - Payouts: includes
customer.account_number,customer.bank_code,customer.bank_name.
Response summary values
The exact string in data.response_summary depends on what happened. Common values:
Card payments
"Payment completed"—response_code: "10000""Declined - 3DS authentication failed"—"20151""Declined - Not sufficient funds"—"20051""Declined - Do not honour"—"20005""Declined - Lost Card"/"Declined - Stolen Card, Pick up"—"20041"/"20043""Declined - Suspected fraud"—"20083""Declined - Unspecified failure"—"20030"(generic)
Virtual account payments
"Payment of <amount> <currency> received from <sender>"— successful collection"Amount mismatch: expected <amount> <ccy> but received <amount> <ccy>"— partial / over payment"Payment expired without receiving funds"— VA expired without payment
Payouts
"Payment completed"— successful payout"Payment failed"— failed payout
Signature verification
Every webhook must be verified. An unverified webhook could be forged by an attacker who guesses your URL.
Algorithm
expected_signature = HMAC_SHA256(
key = your_hash_key,
input = raw_request_body
)
is_valid = constant_time_equals(
hex(expected_signature),
request.header["X-Payline-Signature"]
)
Three rules:
- Use the raw request body bytes — not a re-serialized JSON string. Frameworks that auto-parse JSON often discard whitespace; signatures will not match. Read the raw body before parsing.
- Use constant-time comparison —
hmac.compare_digest,crypto.timingSafeEqual,hash_equals, etc. Don't use==. - Reject any webhook where the signature doesn't match. Return a 4xx status and ignore the payload.
Code samples
const express = require('express');
const crypto = require('crypto');
const app = express();
const HASH_KEY = process.env.PAYLINE_WEBHOOK_HASH_KEY;
// IMPORTANT: capture the raw body BEFORE express parses it
app.post('/webhooks/payline',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Payline-Signature');
const rawBody = req.body; // Buffer, raw bytes
const expected = crypto
.createHmac('sha256', HASH_KEY)
.update(rawBody)
.digest('hex');
const valid = signature && crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
if (!valid) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody.toString());
// ... process event ...
res.status(200).send('ok');
}
);import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
HASH_KEY = os.environ['PAYLINE_WEBHOOK_HASH_KEY']
@app.route('/webhooks/payline', methods=['POST'])
def payline_webhook():
signature = request.headers.get('X-Payline-Signature', '')
raw_body = request.get_data() # raw bytes
expected = hmac.new(
HASH_KEY.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
# ... process event ...
return 'ok', 200<?php
$hashKey = getenv('PAYLINE_WEBHOOK_HASH_KEY');
$signature = $_SERVER['HTTP_X_PAYLINE_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', $rawBody, $hashKey);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
// ... process event ...
http_response_code(200);
echo 'ok';require 'openssl'
class WebhooksController < ApplicationController
HASH_KEY = ENV.fetch('PAYLINE_WEBHOOK_HASH_KEY')
# skip Rails' CSRF + body re-parsing
skip_before_action :verify_authenticity_token
def payline
raw_body = request.body.read
signature = request.headers['X-Payline-Signature'].to_s
expected = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
HASH_KEY,
raw_body
)
unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
head :unauthorized and return
end
event = JSON.parse(raw_body)
# ... process event ...
head :ok
end
endpackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
)
var hashKey = []byte(os.Getenv("PAYLINE_WEBHOOK_HASH_KEY"))
func paylineWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, hashKey)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
got := r.Header.Get("X-Payline-Signature")
if !hmac.Equal([]byte(expected), []byte(got)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event map[string]any
json.Unmarshal(body, &event)
// ... process event ...
w.WriteHeader(http.StatusOK)
}Responding to webhooks
Acknowledge fast, process async
Your endpoint must return a 2xx status code within 10 seconds, otherwise Payline treats the delivery as failed and retries. If your handler does meaningful work (DB writes, downstream API calls, email sending), do it asynchronously:
- Verify the signature.
- Persist the raw event to a queue or table keyed by
event_id. - Respond
200 OKimmediately. - Process the event in a background worker.
If you skip step 3, slow downstream services will trigger duplicate deliveries on retry.
Status codes
| Code | Behaviour |
|---|---|
2xx | Delivered — no retry. |
4xx | Treated as failed — Payline retries. Use this to actively refuse (e.g., invalid signature). |
5xx | Treated as failed — Payline retries. |
| Timeout (>10s) | Treated as failed — Payline retries. |
The response body is discarded — don't put anything important in it.
Retry behavior
If your endpoint doesn't return 2xx, Payline retries with exponential backoff plus ±50% jitter. Up to 8 attempts total before the webhook is dead-lettered.
| Attempt | Base delay | With jitter (approx) |
|---|---|---|
| 1 | immediate | immediate |
| 2 | 30 seconds | 15–45 seconds |
| 3 | 2 minutes | 1–3 minutes |
| 4 | 10 minutes | 5–15 minutes |
| 5 | 30 minutes | 15–45 minutes |
| 6 | 1 hour | 30 min – 1.5 hr |
| 7 | 4 hours | 2–6 hours |
| 8 | 12 hours | 6–18 hours |
Total retry window: ~18 hours.
Idempotency
Webhooks can be delivered more than once. This happens when:
- Your endpoint returned
2xxbut the response didn't reach Payline in time (network blip). - A retry from a previous failure arrives after a successful retry.
- Internal Payline retries due to transient infrastructure issues.
To handle this safely, deduplicate using event_id (also available as the X-Payline-Idempotency-Key header — both carry the same UUID per delivery).
Recommended pattern
key = event['event_id'] # or request.headers['X-Payline-Idempotency-Key']
if already_processed(key):
return 200, 'ok' # idempotent ack — don't process twice
with database.transaction():
mark_processed(key)
process_event(event)
return 200, 'ok'Store event_id with a unique constraint in your database. A duplicate insert is the fastest, most reliable dedup check.
Testing in sandbox
In the sandbox environment, you can trigger webhooks without real money:
- Card payments: use the test card numbers documented in the Payments reference.
- Virtual account collections: use
POST /api/virtual-accounts/simulateto simulate an incoming bank transfer. - Payouts: use the sandbox payout provider — every payout terminates in
successorfailedimmediately based on the amount.
For local development, services like webhook.site or ngrok let you receive webhooks without exposing a public HTTPS endpoint. Note that production webhook URLs must be HTTPS — ngrok provides HTTPS by default.
Troubleshooting
Signature mismatch
- You're hashing a re-serialized JSON string, not the raw body. Capture raw bytes before any framework parses them.
- You used a non-constant-time string compare (
==). Usehmac.compare_digest/timingSafeEqual/hash_equals. - Your hash key got URL-encoded or wrapped in quotes somewhere in your secrets pipeline. Print it and compare against the dashboard.
- You rotated the key but your servers haven't picked up the new value.
My endpoint never receives the webhook
- Confirm the configuration is
enabled: trueand the URL is reachable over public HTTPS. - Check that you subscribed to the right event types. A subscription to
payment.successwon't deliverpayment.failed. - Inspect the delivery history on the dashboard — it shows the last response code per attempt.
Duplicate webhooks
- Expected behaviour after a retry. Implement idempotency using
event_id.