Skip to main content

Webhook: Payment Notification

Overview​

This webhook is triggered whenever a payment is successfully processed. It allows your system to receive real-time updates about a transaction.

Your application must be able to receive HTTP POST requests from our server at the webhook URL you have configured.
Upon receiving the webhook, it is mandatory to verify the payment status using our Payment Verification API before taking any action.


Example Payload​

Below is an example of the JSON payload sent to your webhook endpoint:

{
"reference": "PISL2408200000000035",
"amount": "3000",
"amount_paid": "3051",
"payment_status": "paid",
"payment_channel": "card",
"paid_at": "2024-08-20T11:43:57.000Z",
"payment_name": "Abc Sample",
"payment_item_id": 1,
"meta_data": null
}

Field Description​

FieldTypeDescription
referencestringUnique transaction reference generated for the payment.
amountstringThe expected amount for the transaction.
amount_paidstringThe actual amount paid by the customer (may include charges).
payment_statusstringCurrent status of the transaction. Possible value: paid,unpaid
payment_channelstringThe method used for payment (e.g., card, bank, bank-transfer).
paid_atstring (ISO8601)Timestamp indicating when the payment was completed.
payment_namestringName or description of the payment item.
payment_item_idnumberIdentifier of the specific payment item.
meta_dataobject / nullAdditional metadata (if any) sent with the transaction.

Verify webhook signatures​

Merchants can optionally configure a webhook secret in the Merchant Dashboard webhook settings. When a webhook secret is configured, Payisland signs every merchant webhook request. When no webhook secret is configured, webhook requests remain unsigned and the signature headers are omitted.

Payisland sends these headers on signed webhook requests:

X-Payislands-Signature: sha256=<hex-encoded HMAC>
X-Payislands-Timestamp: <Unix timestamp in seconds>

The signed message is constructed exactly as:

<timestamp>.<raw_request_body>

The signature is calculated as:

HMAC_SHA256(timestamp + "." + raw_request_body, webhook_secret)
Use the raw request body

You must use the exact raw HTTP request body bytes received from Payisland, before JSON parsing or reserialization. Reserializing parsed JSON may change whitespace or property ordering and cause signature verification to fail. Make sure your web framework, middleware, and proxy preserve the body unchanged.

When signature verification is enabled in your application:

  1. Read X-Payislands-Signature and X-Payislands-Timestamp.
  2. Reject the request if either required header is absent.
  3. Confirm that the timestamp is within a configurable tolerance. A five-minute (300-second) tolerance is recommended to reduce replay attacks.
  4. Construct <timestamp>.<raw_request_body> using the timestamp header exactly as received and the unmodified request body.
  5. Calculate the HMAC-SHA256 using the merchant's webhook secret.
  6. Add the sha256= prefix to the hex-encoded HMAC.
  7. Compare the expected and received signatures using a timing-safe comparison.
  8. Only process the webhook after successful verification.
  9. Apply event or transaction idempotency so retries do not cause duplicate processing.

Verification examples​

Register express.raw() for the webhook route before any JSON body parser that could consume or transform the request body.

const crypto = require("node:crypto");
const express = require("express");

const app = express();
const webhookSecret = process.env.PAYISLAND_WEBHOOK_SECRET;
const timestampToleranceSeconds = 5 * 60;

if (!webhookSecret) {
throw new Error("PAYISLAND_WEBHOOK_SECRET is not configured");
}

app.post(
"/webhooks/payisland",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.get("X-Payislands-Signature");
const timestamp = req.get("X-Payislands-Timestamp");

if (!signature || !timestamp) {
return res.status(401).send("Missing webhook signature headers");
}

if (!/^\d+$/.test(timestamp)) {
return res.status(400).send("Malformed webhook timestamp");
}

const timestampSeconds = Number(timestamp);
const nowSeconds = Math.floor(Date.now() / 1000);
if (
!Number.isSafeInteger(timestampSeconds) ||
Math.abs(nowSeconds - timestampSeconds) > timestampToleranceSeconds
) {
return res.status(400).send("Stale webhook timestamp");
}

// req.body is a Buffer because express.raw() runs before JSON parsing.
const hmac = crypto.createHmac("sha256", webhookSecret);
hmac.update(`${timestamp}.`, "utf8");
hmac.update(req.body);
const expectedSignature = `sha256=${hmac.digest("hex")}`;

const expected = Buffer.from(expectedSignature, "utf8");
const received = Buffer.from(signature, "utf8");
const isValid =
expected.length === received.length &&
crypto.timingSafeEqual(expected, received);

if (!isValid) {
return res.status(401).send("Invalid webhook signature");
}

let event;
try {
event = JSON.parse(req.body.toString("utf8"));
} catch {
return res.status(400).send("Invalid JSON payload");
}

// Store/check event.reference (or another unique transaction ID) before
// applying side effects, then accept the event successfully.
await acceptWebhookIdempotently(event);

return res.status(200).json({ status: true });
}
);

Troubleshooting​

  • Parsed JSON was used instead of the raw body: Capture the exact HTTP request bytes before parsing. JSON reserialization can change whitespace or property ordering.
  • The timestamp was treated as milliseconds: X-Payislands-Timestamp contains Unix time in seconds.
  • The sha256= prefix was omitted: Compare the received header with sha256= followed by the hex-encoded HMAC.
  • Normal string equality was used: Use a timing-safe comparison such as crypto.timingSafeEqual, hash_equals, or hmac.compare_digest.
  • A proxy or middleware transformed the body: Configure proxies, body parsers, and middleware to pass the webhook body through unchanged.
  • The secrets do not match: Confirm that PAYISLAND_WEBHOOK_SECRET contains the same webhook secret configured in the Merchant Dashboard.

Verification Step (Mandatory)​

After receiving a webhook, your system must call our Verification endpoint to confirm the authenticity and current status of the transaction.

Example Response​

{
"status": true,
"message": "",
"data": {
"reference": "PISL2509280000000238",
"payment_status": "pending",
"amount": "1000",
"payment_item": { /* complete payment item object */ },
"customer": {
"id": 46,
"customer_tag": "Cus_208FZen2hmLqKTcTlj8hiV1PdR",
"first_name": "John",
"last_name": "Doe"
},
"business": {
"business_name": "Dune Abc"
}
},
"statusCode": 200
}

⚠️ Important:
Only treat the payment as successful after verifying through this endpoint. Do not rely solely on the webhook payload.


Expected Response​

Your server must respond with HTTP 200 OK to acknowledge receipt of the webhook.

Example response:

{
"status": true,
"message": "Webhook received successfully"
}

If your endpoint fails to respond with a 200 status code, we will retry delivery multiple times before marking the webhook as failed.


Implementation Tips​

  • Always verify reference via the verification endpoint.
  • Avoid performing heavy operations directly within the webhook handler.
  • Respond quickly 200 OK to prevent retries.