Webhooks
Webhooks are automated messages sent from one app to another when something happens. Pomelo Connect sends webhooks to notify your application whenever a transaction changes state.
Instead of polling the API every few seconds to check whether a transaction state changed, webhooks deliver near-instant notifications. When a transaction's state changes, Pomelo sends an HTTP POST to the webhook URL you provided when creating the transaction (or set as a global webhook in your dashboard).
Webhook advantages over long-polling:
- Efficiency — webhooks are only sent when there's new data, so no wasted API requests.
- Real-time updates — near-instantaneous notifications, ensuring you have current transaction data.
- Less complex — no need to maintain polling loops in your code.
Webhooks are particularly useful for events that occur infrequently or unpredictably (e.g. completion of a payment transaction). Without webhooks, your app might constantly re-check transaction state. With webhooks, Pomelo Connect notifies your app as soon as state changes.
Webhook configurations
Three ways to set up a webhook:
| Method | Description |
|---|---|
| Global Webhook | Set a global webhook in the merchant dashboard under Connect → Webhooks. Receives updates about every transaction. |
| Single-Transaction Webhook | Specify a webhook URL in a single transaction's API request. Receives updates for only that transaction. |
| Webhook Apps | Useful for integrations like Zapier or IFTTT, or to segment webhook events. Created programmatically via the API. |
If you set up multiple of these (e.g. a global webhook + per-transaction webhook + a webhook app), events post to ALL of them — they're additive, not exclusive.
Identifying an incoming webhook:
| Property | Value |
|---|---|
| HTTP Method | POST |
X-Originator header | Pomelo-Webhooks |
Example webhook body
{
"created": "2023-06-09T08:46:45.899Z",
"updated": "2023-06-09T08:46:45.899Z",
"deleted": false,
"eventType": "NOTIFY_TRANSACTION_CHANGE",
"transactionId": "6482d462ce6c690008e85e42",
"state": "CONFIRMED",
"signature": "wzczRUp5i/0DqS5LzGVQUw==",
"amount": 1400,
"amountFractional": 14,
"amountFormatted": "GBP 14.00",
"currency": "GBP",
"provider": "debit_credit_card",
"qrCode": {
"url": "https://qr.example.com/transactions/6482d462ce6c690008e85e42.png"
},
"originalSignature": "wzczRUp5i/0DqS5LzGVQUw==",
"externalSource": "Pomelo Connect Gateway",
"id": "6482e6f5bcf6fa0008683c2f"
}
Setting a per-transaction webhook
NodeJS:
const axios = require('axios');
async function createPayment() {
const request = {
amount: 1000,
currency: 'GBP',
webhook: 'https://yourwebsite.com/webhook-endpoint',
// ... additional parameters
};
const response = await axios.post(
'https://api.pomelopay.com/public/v2/transactions',
request,
);
// handle response...
}
PHP:
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
function createPayment() {
$request = [
'amount' => 1000,
'currency' => 'GBP',
'webhook' => 'https://yourwebsite.com/webhook-endpoint',
];
$client = new Client();
$response = $client->post(
'https://api.pomelopay.com/public/v2/transactions',
['json' => $request],
);
// handle response...
}
Python:
import requests
def create_payment():
request = {
'amount': 1000,
'currency': 'GBP',
'webhook': 'https://yourwebsite.com/webhook-endpoint',
}
response = requests.post(
'https://api.pomelopay.com/public/v2/transactions',
json=request,
)
# handle response...
Processing incoming webhooks
When you receive a webhook, always re-query the API to get the authoritative final state before doing anything irreversible (shipping orders, marking invoices paid, granting access). Webhooks can be replayed or, without signature verification, spoofed.
NodeJS / Express:
const axios = require('axios');
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook-endpoint', async (req, res) => {
const webhookData = req.body;
// (Optional) Verify webhook signature — see below.
const transactionId = webhookData.transactionId;
const response = await axios.get(
`https://api.pomelopay.com/public/v2/transactions/${transactionId}`,
);
// ... process the authoritative response
res.status(200).send('OK');
});
app.listen(3000);
Python / Flask:
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/webhook-endpoint', methods=['POST'])
def webhook_endpoint():
webhook_data = request.json
# (Optional) Verify webhook signature — see below.
transaction_id = webhook_data['transactionId']
response = requests.get(
f'https://api.pomelopay.com/public/v2/transactions/{transaction_id}',
)
# ... process the authoritative response
return 'OK', 200
if __name__ == '__main__':
app.run(port=3000)
PHP / Slim:
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
$app = AppFactory::create();
$app->addBodyParsingMiddleware();
$client = new Client();
$app->post('/webhook-endpoint', function (Request $request, $response) use ($client) {
$webhookData = $request->getParsedBody();
// (Optional) Verify webhook signature — see below.
$transactionId = $webhookData['transactionId'];
$apiResponse = $client->get(
"https://api.pomelopay.com/public/v2/transactions/{$transactionId}",
);
// ... process the authoritative response
$response->getBody()->write('OK');
return $response;
});
$app->run();
Webhook signature verification
Verifying the signature lets you confirm a webhook actually came from Pomelo and wasn't sent by an attacker who guessed your endpoint URL.
The signature is constructed from a nonce, timestamp, and your private API key, hashed with SHA-256.
Verification algorithm:
- Extract these headers from the incoming request:
X-Signature-Nonce— unique per-request identifierX-Signature-Timestamp— request timestampX-Signature— the signature to verify
- Concatenate
nonce + timestamp + apiKeyinto asignString. - Compute the SHA-256 hex digest of
signString. - Compare to the received
X-Signature. Match → authentic.
NodeJS:
const crypto = require('crypto');
function verifyWebhookSignature(headers, apiKey) {
const {
'X-Signature-Nonce': nonce,
'X-Signature-Timestamp': timestamp,
'X-Signature': signature,
} = headers;
const signString = `${nonce}${timestamp}${apiKey}`;
const generated = crypto.createHash('sha256').update(signString).digest('hex');
return signature === generated;
}
PHP:
<?php
function verifyWebhookSignature($headers, $apiKey) {
$nonce = $headers['X-Signature-Nonce'];
$timestamp = $headers['X-Signature-Timestamp'];
$signature = $headers['X-Signature'];
$signString = $nonce . $timestamp . $apiKey;
$generated = hash('sha256', $signString);
return $signature === $generated;
}
Python:
import hashlib
def verify_webhook_signature(headers, api_key):
nonce = headers.get('X-Signature-Nonce')
timestamp = headers.get('X-Signature-Timestamp')
received = headers.get('X-Signature')
sign_string = f"{nonce}{timestamp}{api_key}"
generated = hashlib.sha256(sign_string.encode()).hexdigest()
return received == generated
Deprecated transaction signature verification
The older originalSignature field can be verified using your private API key with an MD5 hash. This method is deprecated as of 2023-09-01 — use the SHA-256 header-based signature above for new integrations, and always re-query the API for the source-of-truth state.
const crypto = require('crypto');
const querystring = require('querystring');
function verifyLegacySignature(webhookData, apiKey) {
const { amount, currency, originalSignature } = webhookData;
const calculated = crypto
.createHash('md5')
.update(querystring.stringify({ amount, currency, apiKey }))
.digest('base64');
return calculated === originalSignature;
}