WebHooks
Webhooks are server to server notifications. You give PayLolly the urls in the webHooks object of the payment request (hosted, async or sync) and PayLolly calls them as the payment progresses.
| Field | Triggered | Body |
|---|---|---|
webHooks.completed |
whenever the payment reaches a final state (approved, declined or error). Not sent while the payment is still pending (for example during 3D Secure) | the payment status, see Payment webhooks |
webHooks.approved |
only when the payment is approved | the payment status |
webHooks.txUpdate |
whenever a transaction of the payment changes (capture, void, refund...) | the transaction status, see Transaction update webhook |
Note
If both set, and transaction is approved, both hooks will be invoked
Note
For a hosted payment the same webHooks object applies to the whole flow: the cardholder's form, 3D Secure and the return to your site. You get one notification per event, not one per page.
Delivery
- Every webhook is an HTTP POST with a JSON body (
content-type: application/json). - Success is any 2xx response. Anything else (another status, a timeout or a connection error) is a failure and is retried.
- PayLolly waits up to 60 seconds for your answer. Your endpoint should answer much faster (see the notes below): do the work after answering.
- Retries: up to 26 attempts: the first retry after 1 minute, then after 5 minutes, then after 10 minutes, then hourly, which is about a day in all. After the last attempt the notification is dropped, so reconcile with
GetPaymentStatusfor payments you never heard about. - The JSON uses camel case names, properties without a value are left out (they are not sent as
null), and enumerations are sent as their names (for example"status": "Approved").
Warning
Once Invoked, system will keep trying for a period of about 24 hours to send the webrequest and awaits a 2xx response
Important Notes
- Please note webhooks are called from a different set of IP addresses, and being webhooks, you must not assume anything
- Additionally please understand that webhooks will NOT contain any session data or variables
- Webhooks should be coded to be called multiple times without failure and should not take longer than 10 seconds to process. Use
paymentId(and, for transactions, the transactionid) to recognise a repeat - Webhooks can arrive in a different order from the one the events happened in (a retry of an earlier one may arrive after a later one): use the content, not the arrival order
- Webhooks should create Traces in your application to allow you to understand execution, flow and status
- Webhooks are not possible to be tested on localhost, and you should use a proxy such as ngrok or a remote webhook tester such as https://webhook.site to test
Validation
Webhook calls can be validated using 2 methods
- IP FILTER - in production all webhook calls originate from webhooksource.paylolly.app
- Webhook calls contain a header called sha512, which contains the Sha512 of the Body, contcatenated with the merchant private key (API 1.7.10 and above)
The header value is the upper case hexadecimal SHA-512 of the exact body text (UTF-8) followed directly by your merchant key (the MerchantKey you use in your API calls, written as a guid with dashes in lower case). Compute it over the raw body as received, before parsing it, and compare in a constant time.
using System.Security.Cryptography;
using System.Text;
static bool IsValid(string rawBody, string merchantKey, string sha512Header)
{
var hash = SHA512.HashData(Encoding.UTF8.GetBytes(rawBody + merchantKey));
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(Convert.ToHexString(hash)),
Encoding.ASCII.GetBytes(sha512Header.ToUpperInvariant()));
}
Sha512 example
content:
{"paymentId":"5f723143-5cc4-4462-b469-cd515c6e8418","merchantId":"dbdab964-82ee-4b09-a26b-68403c31afa7","currency":"EUR","amount":1.0,"result":{"status":"Error","message":"HPS02 payment cancelled"},"merchantRef":"4a72fa2a-e5f5-422f-885d-9fdb40c1ba26"}35236fcf-12f4-4c94-b4ba-5d236457b68a
hash:
E82B614E68B14389C893F61BF54F3FA4E57629B2ABE1BEC9CEFD994B79633EA0C122ACB69299C47D79DDC18B319C06356331AAD42033D0284F06A6BAD791B6D8
Payment webhooks (completed and approved)
Important
Webhooks are all POST
The body is the same as the response of Payment/GetPaymentStatus:
| Field | Description |
|---|---|
| paymentId, merchantId, accountId | The payment, your merchant and the merchant account used |
| currency, amount | As requested |
| merchantRef | Your reference |
| cardData | maskedPAN, expiryYYMM, brand, type, country, and the card token when the card was saved (recurrence OneClick) |
| threeDSData | The 3D Secure authentication data, when the payment was authenticated |
| result | status (Approved, Declined, Error), message, processorId, authCode, traceId, standardHostCode, mastercardMacCode |
| agreement | Only when the request carried an agreement object, see below |
| remarks | Free text from PayLolly, when there is one |
headers:
connection close
content-length 309
content-type application/json; charset=utf-8
accept-encoding gzip, deflate, br
user-agent RestSharp/108.0.2.0
accept application/json, text/json, text/x-json, text/javascript, application/xml, text/xml
host webhook.site
sha512 E82B614E68B14389C893F61BF54F3FA4E57629B2ABE1BEC9CEFD994B79633EA0C122ACB69299C47D79DDC18B319C06356331AAD42033D0284F06A6BAD791B6D8
Sample webhook call - error
{
"paymentId": "8c08a2c5-7e9c-4a28-a29f-58d2a2cb3a2e",
"merchantId": "dbdab964-82ee-4b09-a26b-68403c31afa7",
"currency": "EUR",
"amount": 1,
"cardData": {
"maskedPAN": "400000*0002",
"expiryYYMM": "3012"
},
"result": {
"status": "Error",
"message": "HPS02 payment cancelled"
},
"merchantRef": "9e31818e-407b-48d8-8bbe-eb19fa565734"
}
Sample webhook call - declined
{
"paymentId": "0198ab5c-1c10-7a33-8c0e-9d3f6d1b6e21",
"merchantId": "dbdab964-82ee-4b09-a26b-68403c31afa7",
"accountId": "81de44cb-4b70-4207-9f5e-54355491a5cc",
"currency": "EUR",
"amount": 1,
"cardData": {
"maskedPAN": "400000*0002",
"expiryYYMM": "3012",
"brand": "Visa",
"type": "Credit",
"country": "GB"
},
"result": {
"status": "Declined",
"processorId": "45f34caa-5064-4476-bb94-d8be0a500251",
"message": "Do not honour",
"standardHostCode": "05"
},
"merchantRef": "9e31818e-407b-48d8-8bbe-eb19fa565722"
}
Sample webhook call - approved
{
"paymentId": "597ed30f-9510-4990-ad07-73135ba8fcb3",
"merchantId": "dbdab964-82ee-4b09-a26b-68403c31afa7",
"accountId": "81de44cb-4b70-4207-9f5e-54355491a5cc",
"currency": "EUR",
"amount": 1,
"cardData": {
"maskedPAN": "400000*0002",
"expiryYYMM": "3012",
"token": "9C2C6412351A5BED6529ED5698DE395E",
"brand": "Visa",
"type": "Credit",
"country": "GB"
},
"threeDSData": {
"cavv": "AAcBAFAUUgAAADTOl4FpdQAAAAA=",
"eci": "5",
"version": "2.2.0",
"authenticationType": "02",
"transStatus": "Y",
"dsId": "97f8af6f-7b7c-4084-95f1-c6560fe75221",
"acsId": "ecfd38fc-0d9f-47b1-a61e-f9767958ae65"
},
"result": {
"status": "Approved",
"processorId": "45f34caa-5064-4476-bb94-d8be0a500251",
"message": "Dummy success",
"authCode": "123456"
},
"merchantRef": "9e31818e-407b-48d8-8bbe-eb19fa565721"
}
The agreement block
When the request carried an agreement object (see Hosted Payment) the payment webhooks carry the outcome of the registration:
"agreement": {
"agreementId": "0198ab5c-6f37-7a02-9d7e-3a5e2c1f4b10",
"status": "Created",
"agreementRef": "TOPUP-1",
"consentStatus": "Recorded"
}
| Field | Values |
|---|---|
| agreementId | The id to quote as agreementId on later payments. Absent when nothing was registered |
| status | Created, Updated (the same customer, card and agreementRef again) or NotCreated |
| reason | Only with NotCreated: ConsentDeclined (optional consent not ticked), AlreadyRegistered (hosted, optional consent: the saved card already has an agreement with these terms), PaymentNotApproved, Disabled (agreements are not enabled for the merchant) or Error |
| agreementRef | Your agreementRef, as sent |
| consentStatus | Recorded, or LegacyUnrecorded when no consent evidence is held for the agreement |
Note
The agreement is registered after the payment is approved, and a failure to register never fails the payment. With "reason": "Error" PayLolly keeps retrying the registration in the background. When a retry gives the final outcome the payment is updated (GetPaymentStatus shows it) and your completed and approved webhooks are sent again with the final agreement block, so be ready to receive a second notification for the same payment (webhooks must be safe to repeat anyway).
Note
Payments without an agreement object have no agreement property.
Transaction update webhook (txUpdate)
Sent to webHooks.txUpdate whenever a transaction of the payment changes. The body is the transaction status:
| Field | Description |
|---|---|
| id | The transaction id |
| paymentId | The payment the transaction belongs to |
| merchantId, accountId | Your merchant and the merchant account |
| currency | The currency |
| authAmount, captureAmount, refundAmount | Amounts authorised, captured and refunded so far |
| state | Decline, Error, Auth, Capture, Void, PartialRefund, Refund or Oct |
| finalCapture | true when the capture was the final one |
| created | When the transaction was created (UTC) |
{
"id": "6a0b6c58-0a3c-4c0e-bb3b-1f8f0c0c7a11",
"merchantId": "dbdab964-82ee-4b09-a26b-68403c31afa7",
"accountId": "81de44cb-4b70-4207-9f5e-54355491a5cc",
"currency": "EUR",
"authAmount": 10,
"captureAmount": 10,
"refundAmount": 0,
"state": "Capture",
"paymentId": "597ed30f-9510-4990-ad07-73135ba8fcb3",
"finalCapture": true,
"created": "2026-10-16T09:12:44.381Z"
}
Checklist for your endpoint
- Answer
200quickly, then do the work - Verify the
sha512header (or restrict the source address) - Be safe against the same webhook arriving more than once and out of order
- Read
agreement.statusandagreement.reasonif you save cards - Reconcile with
Payment/GetPaymentStatusfor anything you did not hear about