Webhooks

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

  1. You register a webhook URL on the Payline dashboard along with the event categories you care about. We generate a unique hash_key and return it to you once — store it somewhere safe; you'll need it to verify signatures.
  2. An event fires on your account (e.g. a card payment succeeds).
  3. Payline sends a POST request to your configured URL within seconds. The payload is signed with HMAC-SHA256 using your hash_key.
  4. Your server verifies the signature, responds with 2xx, and processes the event asynchronously.
  5. 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:

FieldRequiredDescription
urlyesThe HTTPS URL Payline will POST events to
subscribed_eventsyesArray of categories. Any of: payment, payout

You can also toggle the configuration on or off, or rotate the signing key, at any time.

⚠️

The hash_key is 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.

EventFires when
payment.successA payment completed successfully — card payment, virtual account collection, etc.
payment.failedA payment terminally failed — card decline, VA amount mismatch, VA expiration, etc. Check data.response_summary for the reason.
`payment.mismatchA dynamic virtual account payment mismatch
payout.successA payout completed successfully.
payout.failedA 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

HeaderDescription
Content-Typeapplication/json
X-Payline-SignatureHex-encoded HMAC-SHA256 of the raw request body, signed with your hash_key.
X-Payline-Idempotency-KeyThe 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

FieldTypeDescription
idstring (UUID)Webhook delivery ID. Matches X-Payline-Idempotency-Key header.
event_idstring (UUID)Same UUID as id — the canonical identifier you should use for deduplication.
eventstringOne of payment.success, payment.failed, payment.mismatch, payout.success, payout.failed.
created_atstring (RFC 3339)When Payline created the delivery record.
dataobjectTransaction payload — same shape as the API response for that resource.
data.statusstringTerminal status: success or failed.
data.response_codestringInternal code summarising the outcome — e.g., "10000" for success.
data.response_summarystringHuman-readable explanation. Examples below.
data.amountnumberAmount in minor unit (e.g., kobo for NGN).
data.currencystringISO 4217 currency code.

The exact shape of data varies by payment method:

  • Card payments: includes source (card details) and customer.
  • 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:

  1. 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.
  2. Use constant-time comparisonhmac.compare_digest, crypto.timingSafeEqual, hash_equals, etc. Don't use ==.
  3. 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
end
package 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:

  1. Verify the signature.
  2. Persist the raw event to a queue or table keyed by event_id.
  3. Respond 200 OK immediately.
  4. Process the event in a background worker.

If you skip step 3, slow downstream services will trigger duplicate deliveries on retry.

Status codes

CodeBehaviour
2xxDelivered — no retry.
4xxTreated as failed — Payline retries. Use this to actively refuse (e.g., invalid signature).
5xxTreated 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.

AttemptBase delayWith jitter (approx)
1immediateimmediate
230 seconds15–45 seconds
32 minutes1–3 minutes
410 minutes5–15 minutes
530 minutes15–45 minutes
61 hour30 min – 1.5 hr
74 hours2–6 hours
812 hours6–18 hours

Total retry window: ~18 hours.


Idempotency

Webhooks can be delivered more than once. This happens when:

  • Your endpoint returned 2xx but 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/simulate to simulate an incoming bank transfer.
  • Payouts: use the sandbox payout provider — every payout terminates in success or failed immediately 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 HTTPSngrok 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 (==). Use hmac.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: true and the URL is reachable over public HTTPS.
  • Check that you subscribed to the right event types. A subscription to payment.success won't deliver payment.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.