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
| Field | Type | Description |
|---|---|---|
reference | string | Unique transaction reference generated for the payment. |
amount | string | The expected amount for the transaction. |
amount_paid | string | The actual amount paid by the customer (may include charges). |
payment_status | string | Current status of the transaction. Possible value: paid,unpaid |
payment_channel | string | The method used for payment (e.g., card, bank, bank-transfer). |
paid_at | string (ISO8601) | Timestamp indicating when the payment was completed. |
payment_name | string | Name or description of the payment item. |
payment_item_id | number | Identifier of the specific payment item. |
meta_data | object / null | Additional 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)
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.
Recommended verification flow
When signature verification is enabled in your application:
- Read
X-Payislands-SignatureandX-Payislands-Timestamp. - Reject the request if either required header is absent.
- Confirm that the timestamp is within a configurable tolerance. A five-minute (300-second) tolerance is recommended to reduce replay attacks.
- Construct
<timestamp>.<raw_request_body>using the timestamp header exactly as received and the unmodified request body. - Calculate the HMAC-SHA256 using the merchant's webhook secret.
- Add the
sha256=prefix to the hex-encoded HMAC. - Compare the expected and received signatures using a timing-safe comparison.
- Only process the webhook after successful verification.
- Apply event or transaction idempotency so retries do not cause duplicate processing.
Verification examples
- JavaScript
- TypeScript
- Java
- Python
- C#
- PHP
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 });
}
);
import crypto from "node:crypto";
import express, { Request, Response } from "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: Request, res: Response) => {
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");
}
const rawBody = req.body as Buffer;
const hmac = crypto.createHmac("sha256", webhookSecret);
hmac.update(`${timestamp}.`, "utf8");
hmac.update(rawBody);
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: unknown;
try {
event = JSON.parse(rawBody.toString("utf8")) as unknown;
} catch {
return res.status(400).send("Invalid JSON payload");
}
await acceptWebhookIdempotently(event);
return res.status(200).json({ status: true });
}
);
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PayislandWebhookController {
private static final long TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;
private final String webhookSecret = requireWebhookSecret();
private final ObjectMapper objectMapper = new ObjectMapper();
@PostMapping("/webhooks/payisland")
public ResponseEntity<?> receiveWebhook(
@RequestHeader(value = "X-Payislands-Signature", required = false)
String signature,
@RequestHeader(value = "X-Payislands-Timestamp", required = false)
String timestamp,
@RequestBody byte[] rawBody) throws Exception {
if (signature == null || timestamp == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Missing webhook signature headers");
}
if (!timestamp.matches("[0-9]+")) {
return ResponseEntity.badRequest().body("Malformed webhook timestamp");
}
final long timestampSeconds;
try {
timestampSeconds = Long.parseLong(timestamp);
} catch (NumberFormatException exception) {
return ResponseEntity.badRequest().body("Malformed webhook timestamp");
}
long nowSeconds = Instant.now().getEpochSecond();
if (timestampSeconds < nowSeconds - TIMESTAMP_TOLERANCE_SECONDS
|| timestampSeconds > nowSeconds + TIMESTAMP_TOLERANCE_SECONDS) {
return ResponseEntity.badRequest().body("Stale webhook timestamp");
}
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(
webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
hmac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String expectedSignature = "sha256="
+ HexFormat.of().formatHex(hmac.doFinal(rawBody));
boolean isValid = MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
if (!isValid) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid webhook signature");
}
final JsonNode event;
try {
event = objectMapper.readTree(rawBody);
} catch (Exception exception) {
return ResponseEntity.badRequest().body("Invalid JSON payload");
}
// Store/check the event reference before applying side effects.
acceptWebhookIdempotently(event);
return ResponseEntity.ok().body(java.util.Map.of("status", true));
}
private static String requireWebhookSecret() {
String secret = System.getenv("PAYISLAND_WEBHOOK_SECRET");
if (secret == null || secret.isBlank()) {
throw new IllegalStateException(
"PAYISLAND_WEBHOOK_SECRET is not configured");
}
return secret;
}
private void acceptWebhookIdempotently(JsonNode event) {
// Persist the event or transaction ID and apply side effects atomically.
}
}
import hashlib
import hmac
import os
import re
import time
from flask import Flask, jsonify, request
app = Flask(__name__)
webhook_secret = os.environ["PAYISLAND_WEBHOOK_SECRET"].encode("utf-8")
timestamp_tolerance_seconds = 5 * 60
@app.post("/webhooks/payisland")
def receive_payisland_webhook():
signature = request.headers.get("X-Payislands-Signature")
timestamp = request.headers.get("X-Payislands-Timestamp")
if not signature or not timestamp:
return "Missing webhook signature headers", 401
if re.fullmatch(r"[0-9]+", timestamp) is None:
return "Malformed webhook timestamp", 400
timestamp_seconds = int(timestamp)
if abs(int(time.time()) - timestamp_seconds) > timestamp_tolerance_seconds:
return "Stale webhook timestamp", 400
# Capture the unchanged bytes before asking Flask to parse the JSON.
raw_body = request.get_data(cache=True, as_text=False)
signed_message = timestamp.encode("ascii") + b"." + raw_body
digest = hmac.new(
webhook_secret,
signed_message,
hashlib.sha256,
).hexdigest()
expected_signature = f"sha256={digest}"
if not hmac.compare_digest(expected_signature, signature):
return "Invalid webhook signature", 401
event = request.get_json(cache=True)
# Store/check event["reference"] (or another unique transaction ID) before
# applying side effects, then accept the event successfully.
accept_webhook_idempotently(event)
return jsonify(status=True), 200
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var webhookSecret = Environment.GetEnvironmentVariable(
"PAYISLAND_WEBHOOK_SECRET");
if (string.IsNullOrEmpty(webhookSecret))
{
throw new InvalidOperationException(
"PAYISLAND_WEBHOOK_SECRET is not configured");
}
const long timestampToleranceSeconds = 5 * 60;
app.MapPost("/webhooks/payisland", async (HttpContext context) =>
{
string signature = context.Request.Headers[
"X-Payislands-Signature"].ToString();
string timestamp = context.Request.Headers[
"X-Payislands-Timestamp"].ToString();
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Missing webhook signature headers");
return;
}
if (!long.TryParse(
timestamp,
NumberStyles.None,
CultureInfo.InvariantCulture,
out long timestampSeconds))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Malformed webhook timestamp");
return;
}
long nowSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (timestampSeconds < nowSeconds - timestampToleranceSeconds
|| timestampSeconds > nowSeconds + timestampToleranceSeconds)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Stale webhook timestamp");
return;
}
// Read the unchanged request bytes before deserializing the JSON.
using var bodyStream = new MemoryStream();
await context.Request.Body.CopyToAsync(bodyStream);
byte[] rawBody = bodyStream.ToArray();
byte[] prefix = Encoding.UTF8.GetBytes(timestamp + ".");
byte[] signedMessage = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signedMessage, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signedMessage, prefix.Length, rawBody.Length);
byte[] secretBytes = Encoding.UTF8.GetBytes(webhookSecret);
byte[] digest = HMACSHA256.HashData(secretBytes, signedMessage);
string expectedSignature = "sha256="
+ Convert.ToHexString(digest).ToLowerInvariant();
bool isValid = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSignature),
Encoding.UTF8.GetBytes(signature));
if (!isValid)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Invalid webhook signature");
return;
}
JsonElement eventData;
try
{
using JsonDocument eventDocument = JsonDocument.Parse(rawBody);
eventData = eventDocument.RootElement.Clone();
}
catch (JsonException)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Invalid JSON payload");
return;
}
// Store/check the event reference before applying side effects.
await AcceptWebhookIdempotentlyAsync(eventData);
context.Response.StatusCode = StatusCodes.Status200OK;
await context.Response.WriteAsJsonAsync(new { status = true });
});
app.Run();
static Task AcceptWebhookIdempotentlyAsync(JsonElement eventData)
{
// Persist the event or transaction ID and apply side effects atomically.
return Task.CompletedTask;
}
<?php
$webhookSecret = getenv('PAYISLAND_WEBHOOK_SECRET');
$timestampToleranceSeconds = 5 * 60;
if ($webhookSecret === false || $webhookSecret === '') {
http_response_code(500);
exit('PAYISLAND_WEBHOOK_SECRET is not configured');
}
$signature = $_SERVER['HTTP_X_PAYISLANDS_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_PAYISLANDS_TIMESTAMP'] ?? '';
$rawBody = file_get_contents('php://input');
if ($signature === '' || $timestamp === '') {
http_response_code(401);
exit('Missing webhook signature headers');
}
if (!ctype_digit($timestamp)) {
http_response_code(400);
exit('Malformed webhook timestamp');
}
$timestampSeconds = (int) $timestamp;
if (abs(time() - $timestampSeconds) > $timestampToleranceSeconds) {
http_response_code(400);
exit('Stale webhook timestamp');
}
$digest = hash_hmac(
'sha256',
$timestamp . '.' . $rawBody,
$webhookSecret
);
$expectedSignature = 'sha256=' . $digest;
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
exit('Invalid webhook signature');
}
try {
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
http_response_code(400);
exit('Invalid JSON payload');
}
// Store/check $event['reference'] (or another unique transaction ID) before
// applying side effects, then accept the event successfully.
acceptWebhookIdempotently($event);
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['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-Timestampcontains Unix time in seconds. - The
sha256=prefix was omitted: Compare the received header withsha256=followed by the hex-encoded HMAC. - Normal string equality was used: Use a timing-safe comparison such as
crypto.timingSafeEqual,hash_equals, orhmac.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_SECRETcontains 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
referencevia the verification endpoint. - Avoid performing heavy operations directly within the webhook handler.
- Respond quickly
200 OKto prevent retries.