Webhook
When someone pays through your payment page, your webshop can be told about it right away. You fill in an address once under Settings > Payment page > Integration (see
Payment page). From that moment on, that address gets a POST with JSON as soon as something happens to a payment.
The setup is deliberately almost identical to Mollie’s. All the event names but one are Mollie’s, the payment in the body looks the same and the signature works the same way. If your webshop already works with a PSP, you will recognise nearly everything.
The three events
| Event | When |
|---|---|
payment.open |
The payer has your payment page in front of them |
payment.claimed |
The payer says they have paid |
payment.paid |
The bank confirms the money has arrived |
payment.open goes out as soon as the payment page is really on screen. That is something other than the link being opened: the page only reports itself with a script, so a link scanner, a crawler or a browser’s prefetch produces no message. Nothing has been paid at that point; this is the starting signal that someone is sitting in front of your payment page. A payer without JavaScript only reports in on ‘I have paid’: payment.open and payment.claimed then follow each other closely.
payment.claimed goes out the moment the payer presses ‘I have paid’, or returns from a signed payment request at their own bank. The payer is still on the page at that point. This is not a confirmation yet: it is the payer saying so, not the bank.
payment.paid goes out as soon as the transfer is on your account and has been matched to the payment. This is the confirmation you are waiting for, and the only status on which you should release an order.
The list of online payments in the cash register names the status of a payment with these same three words, so you can lay it next to what your webshop received. A visit that nothing further came of has no message and no word: its status stays empty there.
There is one thing here that works differently than at a PSP: the cash register hears from your bank through Ponto, and Ponto reads your account four times a day. So count on hours between payment.claimed and payment.paid, not seconds. Only that last one comes from the bank: payment.open and payment.claimed are things the payment page sees happen itself, so you get those without a bank connection too. If you do not have one, those two are what you hear and payment.paid stays away.
The call
A call is a POST with Content-Type: application/json and these headers:
| Header | Contents |
|---|---|
X-Usecue-Event |
payment.open, payment.claimed or payment.paid |
X-Usecue-Signature |
sha256= followed by the HMAC-SHA256 of the body |
User-Agent |
Usecue-POS-Webhook/1.0 |
The body holds the payment itself, as it stands at that moment. The status in the body is the event without the payment. in front, exactly as with Mollie.
payment.open
{
"resource": "payment",
"id": "pay_123",
"mode": "live",
"amount": { "currency": "EUR", "value": "10.00" },
"description": "Order 12345",
"method": "epc",
"status": "open",
"createdAt": "2026-09-11T09:13:37+02:00",
"expiresAt": "2026-09-25T09:13:37+02:00",
"details": {
"transferReference": "Order 12345",
"consumerName": null,
"consumerAccount": null,
"consumerAccountDigits": null,
"remittanceInformation": null,
"paymentLink": null
},
"_links": {
"dashboard": { "href": "https://pos.usecue.com/pos/payments/details/123", "type": "text/html" },
"documentation": { "href": "https://pos.usecue.com/p/docs/payment-page/webhook/", "type": "text/html" }
}
}
payment.claimed
{
"resource": "payment",
"id": "pay_123",
"mode": "live",
"amount": { "currency": "EUR", "value": "10.00" },
"description": "Order 12345",
"method": "epc",
"status": "claimed",
"createdAt": "2026-09-11T09:13:37+02:00",
"expiresAt": "2026-09-25T09:13:37+02:00",
"details": {
"transferReference": "Order 12345",
"consumerName": null,
"consumerAccount": null,
"consumerAccountDigits": "4300",
"remittanceInformation": null,
"paymentLink": null
},
"_links": {
"dashboard": { "href": "https://pos.usecue.com/pos/payments/details/123", "type": "text/html" },
"documentation": { "href": "https://pos.usecue.com/p/docs/payment-page/webhook/", "type": "text/html" }
}
}
payment.paid
{
"resource": "payment",
"id": "pay_123",
"mode": "live",
"amount": { "currency": "EUR", "value": "10.00" },
"description": "Order 12345",
"method": "epc",
"status": "paid",
"createdAt": "2026-09-11T09:13:37+02:00",
"paidAt": "2026-09-11T14:02:00+02:00",
"details": {
"transferReference": "Order 12345",
"consumerName": "J. Jansen",
"consumerAccount": "NL91ABNA0417164300",
"consumerAccountDigits": "4300",
"remittanceInformation": "Order 12345",
"paymentLink": null
},
"_links": {
"dashboard": { "href": "https://pos.usecue.com/pos/payments/details/123", "type": "text/html" },
"documentation": { "href": "https://pos.usecue.com/p/docs/payment-page/webhook/", "type": "text/html" }
}
}
The fields
| Field | Meaning |
|---|---|
id |
The payment, pay_ followed by the number in the cash register |
mode |
live, or test while you are working in Ponto’s sandbox |
amount.value |
The amount as a string, with two decimals |
description |
The description from the payment link |
method |
What the payer paid with, see below |
status |
open, claimed or paid |
createdAt |
When the payer opened the payment page |
expiresAt |
Until when a transfer still belongs to this payment |
paidAt |
When the bank booked the money |
expiresAt appears in a payment.open and a payment.claimed, paidAt only in a payment.paid, just as with Mollie. A transfer arriving after expiresAt is no longer matched to this payment, so no payment.paid follows either.
The method is one of these:
| Method | Meaning |
|---|---|
epc |
The EPC QR code, scanned with the banking app |
iban |
Transferred manually |
ponto |
A payment request, signed at the payer’s own bank |
fallback |
A payment link, for example Tikkie |
noneu |
A payment link for outside the EU, Stripe or PayPal |
details holds what is known about the payer. On a payment.open that is next to nothing: the payer has not filled in anything yet and the bank has not said anything yet. consumerName and consumerAccount come from the bank and are therefore only filled in on payment.paid. consumerAccountDigits are the digits the payer entered themselves at a payment link, remittanceInformation is the description as the bank passed it on, and paymentLink the name of the payment link that was on the screen.
Checking the signature
When you save a webhook URL, the cash register creates a webhook secret. It is shown on the same page. Every call is signed with it, so you know the message comes from us and has not been altered on the way.
Compute the HMAC-SHA256 over the body as you received it, so not over the JSON after you have parsed and re-encoded it:
<?php
$secret = 'the webhook secret';
$body = file_get_contents('php://input');
$signature = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($signature, $_SERVER['HTTP_X_USECUE_SIGNATURE'] ?? '')) {
http_response_code(403);
exit;
}
$payment = json_decode($body, true);
if ($payment['status'] == 'paid') {
// this is where you release the order
}
http_response_code(200);
Compare with hash_equals and not with ==. Do nothing else with a message whose signature does not check out.
Answering and retrying
Answer with a 2xx status. Anything else, including no answer at all, counts as not delivered.
- A call that did not arrive is retried, every hour. Five attempts per event, the first one included, so after about four hours it stops.
- That happens per event: a
payment.openthat did not arrive does not hold uppayment.claimedorpayment.paid. - A call that did arrive is never repeated.
- We wait ten seconds at most for your answer. On
payment.openandpayment.claimedthe payer is standing in front of your payment page meanwhile, so answer immediately and do the real work afterwards.
What came back from each attempt can be read in the cash register, under the payment itself at Online payments.
What to watch out for
payment.openis a visitor, not a payment yet: most of them get no further. So do not turn one into an order. What it is good for: looking up the order it belongs to, seeing that the payer is there, and noticing afterwards that they did not get any further.- Do not rely on the order: a payer who does not press the button but does transfer the money produces a
payment.openand apayment.paid, and nopayment.claimed. So do not sit waiting for a message that may never come. - One order can produce more than one
id: someone who opens the payment page on another device, or comes back again after pressing ‘I have paid’, starts a new payment with a newpay_number and therefore a newpayment.open. Thedescriptionis what ties all those messages to your order. - Look at
status, not at the number of messages: in theory you can get the same event twice, for instance if our answer disappears on the way while you had already booked it. Use theidas the key and make sure a second message breaks nothing. - Only release on
paid:claimedis the payer saying they have paid. For an order you ship that is too little; for a ‘we have received your order’ it is enough. - Only about incoming money: what you transfer yourself does not reach the cash register and therefore not a webhook either.
Differences from Mollie
The names and the structure are nearly the same, but this is not a PSP. The differences in a row:
- There are three events.
payment.authorized,payment.canceled,payment.expiredandpayment.faileddo not exist here: a transfer that does not come simply does not come. - The middle event is
payment.claimedhere andpayment.pendingat Mollie. At a PSPpendingis a payment on its way; here it is the payer saying they have paid, which is something else. - Mollie knows the status
openas well, but does not call you for it. Here you do hear it: the payment page sees for itself that the payer is there, and that is the only moment you know for certain that your link is being used. - There is no API to look up a payment later. Everything you need is in the body, which is why
_links.selfis missing. - The signature sits in
X-Usecue-Signatureinstead ofX-Mollie-Signature, and the event inX-Usecue-Event. - We try five times, every hour; Mollie ten times in 26 hours.
- There are hours between
claimedandpaid, because the confirmation comes from your own bank and not from a PSP.
Trying it out
Put the bank connection in sandbox mode and you get the same messages with "mode": "test". Then pay a test amount on your own payment page: the payment.open comes as soon as the page is on your screen, the payment.claimed as soon as you press ‘I have paid’, and the payment.paid as soon as Ponto has read the fake bank.
Without a bank connection you can try the first two just as well: fill in a webhook URL, open your own payment page and a payment.open is waiting for you right away.
Stuck? Get in touch.