🇭🇺 Magyar változat:
Merchant_Webhook_API.hu.md
Outbound webhook from the Paynance platform: we send your webshop’s payment events to a URL you provide — signed, with retries and a delivery log.
Machine-readable schema: Merchant_Webhook_API.en.openapi.json
(OpenAPI 3.0.3) — for client and type generation, and for Swagger UI / Redoc rendering.
For a live trial with signature calculation: Merchant_Webhook_API.en.postman_collection.json.
1. Overview
The shopper returning from their browser is not a reliable signal: they can close the tab, the network can drop, and the payment can change state hours later (capture, refund, chargeback). The webhook is the server-to-server channel that tells you for certain what happened.
When we send. Whenever a webshop (VPOS) payment changes state: authorisation, authorisation failure, capture, cancellation, refund, chargeback. We do not send webhooks for POS/terminal transactions.
What we expect from you.
| Response code | 2xx once you have accepted the event — recommended: 202 Accepted |
| Response time | fast (our timeout is 5 s) — move the processing to the background |
| Idempotency | mandatory, keyed on event_id (see section 5) |
| Signature failure | 4xx, never 2xx (see section 4) — recommended: 401 Unauthorized |
We don’t pin the exact code, only its class. Any 2xx counts as accepted, and any 4xx or
5xx triggers a retry. We recommend those two specific codes because they say exactly what is
happening — and because they are what you will see in your own logs when something goes wrong:
202 Accepted— you have taken the event, processing happens in the background. That matches reality: you won’t finish within 5 seconds anyway, whereas200 OKwould suggest that you are done. (200is, of course, just as fine for us.)401 Unauthorized— the signature doesn’t match. It clearly separates the “unknown sender” case from a malformed payload (400) or a processing failure on your side (5xx), so a temporary mismatch after a secret rotation is immediately recognisable in the log.
Every request looks like this:
POST <your URL>
Content-Type: application/json
Paynance-Signature: <hex HMAC-SHA256>
2. Setup
In the Paynance interface: Webshop settings → Webhook (payment notifications).
- Webhook URL —
httpsonly, on a publicly reachable address. We do not accept URLs pointing to private IPs or tohttp. - Signing secret — generated automatically when you save the setting. It is shown masked
(
whsec_550d2a2b…); the Reveal secret button shows the full value at any time. If you lose it from your server, there is no need to rotate: you can read it again here. - Send test event — sends a request to your endpoint immediately and synchronously, and shows the response code on the spot.
- Delivery log — on the same page: which events went out when and with what response code, plus manual redelivery.
Two things worth knowing about the test event:
- Its
event_typeistest, not a synthetic payment event — so your code must not expect a payment event from it. Theevent_id, however, is a real UUID v7, so that your idempotency store behaves exactly as described here. - It is not recorded in the log. The result is visible on screen there and then; it is not searchable afterwards, and it does not affect the endpoint’s failure state.
3. Payload
Every payload uses the same envelope:
| Field | Type | Description |
|---|---|---|
version |
int | Payload schema version. New fields may appear without a bump; renames may not. |
event_id |
string (UUID v7) | The public identifier of the event. Idempotency is built on this. |
event_type |
string | Our versioned event name, see the table below. |
created_at |
string (ISO 8601) | When the event was created on our side. |
data |
object | The contents of the event. |
Fields of the data object:
| Field | Type | Description |
|---|---|---|
psp_reference |
string | The processor reference of this particular operation. The capture and the refund of a payment each get their own psp_reference. |
original_psp_reference |
string | null | The reference of the payment behind the operation. null for payment.authorised/payment.failed. |
merchant_reference |
string | Your order identifier, the one you sent when creating the session. |
transaction_type |
string | payment, capture, cancellation, refund, chargeback or test. |
success |
bool | Whether this particular operation succeeded. |
amount |
object | null | { "value": <minor unit>, "currency": "HUF" } — value is in the smallest currency unit. |
payment_method |
string | null | E.g. visa, mc, applepay. |
reason |
string | null | The reason for the failure or refusal, when there is one. |
event_date |
string | null | The time of the operation in payment processing. |
shopper_email |
string | null | The shopper’s email address, if they provided one. |
store |
string | The Paynance store identifier. |
Event types
event_type |
When |
|---|---|
payment.authorised |
The payment was authorised successfully. |
payment.failed |
The authorisation failed (refusal, bad card, 3DS error). |
payment.captured |
The amount was captured. A partial capture produces several events. |
payment.cancelled |
The authorisation was reversed without a capture. |
payment.refunded |
A refund. A partial refund produces several events. |
payment.chargeback |
The shopper’s bank clawed the amount back. |
test |
The test button on the settings page. |
Examples
payment.authorised
{
"version": 1,
"event_id": "0192f1a2-3b4c-7d5e-8f60-1a2b3c4d5e6f",
"event_type": "payment.authorised",
"created_at": "2026-09-09T12:04:12+00:00",
"data": {
"psp_reference": "NC6HT9CRT65ZGN82",
"original_psp_reference": null,
"merchant_reference": "ORDER-10231",
"transaction_type": "payment",
"success": true,
"amount": {
"value": 249900,
"currency": "HUF"
},
"payment_method": "visa",
"reason": null,
"event_date": "2026-09-09T12:04:11+02:00",
"shopper_email": "vevo@example.com",
"store": "ST322LJ223223K5FQ4NM3JWLK"
}
}
payment.failed
{
"version": 1,
"event_id": "0192f1a3-77bd-7c11-9a04-8f3d5b21c9aa",
"event_type": "payment.failed",
"created_at": "2026-09-09T12:07:45+00:00",
"data": {
"psp_reference": "LK2P9WQ4RT88ZZ01",
"original_psp_reference": null,
"merchant_reference": "ORDER-10232",
"transaction_type": "payment",
"success": false,
"amount": {
"value": 249900,
"currency": "HUF"
},
"payment_method": "mc",
"reason": "Refused",
"event_date": "2026-09-09T12:07:44+02:00",
"shopper_email": "vevo@example.com",
"store": "ST322LJ223223K5FQ4NM3JWLK"
}
}
payment.captured — note that psp_reference is the reference of the capture, while the
payment is in original_psp_reference. Every step of a partial capture sequence gets its own
event.
{
"version": 1,
"event_id": "0192f1b0-04ce-7a52-b7d1-6e0c9f4471d3",
"event_type": "payment.captured",
"created_at": "2026-09-09T14:31:02+00:00",
"data": {
"psp_reference": "QT4M8ZC2XX10PP77",
"original_psp_reference": "NC6HT9CRT65ZGN82",
"merchant_reference": "ORDER-10231",
"transaction_type": "capture",
"success": true,
"amount": {
"value": 249900,
"currency": "HUF"
},
"payment_method": "visa",
"reason": null,
"event_date": "2026-09-09T14:31:01+02:00",
"shopper_email": "vevo@example.com",
"store": "ST322LJ223223K5FQ4NM3JWLK"
}
}
payment.cancelled
{
"version": 1,
"event_id": "0192f1b4-9a20-7f08-8c33-2d5a7e91b402",
"event_type": "payment.cancelled",
"created_at": "2026-09-09T15:02:20+00:00",
"data": {
"psp_reference": "WW90KL22ZZ41MM08",
"original_psp_reference": "NC6HT9CRT65ZGN82",
"merchant_reference": "ORDER-10231",
"transaction_type": "cancellation",
"success": true,
"amount": {
"value": 249900,
"currency": "HUF"
},
"payment_method": "visa",
"reason": null,
"event_date": "2026-09-09T15:02:19+02:00",
"shopper_email": "vevo@example.com",
"store": "ST322LJ223223K5FQ4NM3JWLK"
}
}
payment.refunded
{
"version": 1,
"event_id": "0192f2c1-51ab-7d90-9e77-4b8c0a2f6611",
"event_type": "payment.refunded",
"created_at": "2026-09-10T09:18:40+00:00",
"data": {
"psp_reference": "RF77TT01YY93QQ12",
"original_psp_reference": "NC6HT9CRT65ZGN82",
"merchant_reference": "ORDER-10231",
"transaction_type": "refund",
"success": true,
"amount": {
"value": 100000,
"currency": "HUF"
},
"payment_method": "visa",
"reason": null,
"event_date": "2026-09-10T09:18:39+02:00",
"shopper_email": "vevo@example.com",
"store": "ST322LJ223223K5FQ4NM3JWLK"
}
}
payment.chargeback
{
"version": 1,
"event_id": "0192f9d4-c8e1-7b33-a5f2-90ab13cd4477",
"event_type": "payment.chargeback",
"created_at": "2026-09-21T06:45:03+00:00",
"data": {
"psp_reference": "CB55QQ88WW22EE31",
"original_psp_reference": "NC6HT9CRT65ZGN82",
"merchant_reference": "ORDER-10231",
"transaction_type": "chargeback",
"success": true,
"amount": {
"value": 249900,
"currency": "HUF"
},
"payment_method": "visa",
"reason": "Fraud",
"event_date": "2026-09-21T06:45:02+02:00",
"shopper_email": "vevo@example.com",
"store": "ST322LJ223223K5FQ4NM3JWLK"
}
}
test — the test button on the settings page. There is no payment behind it, so data is
narrower.
{
"version": 1,
"event_id": "0192f1a0-1111-7222-8333-444455556666",
"event_type": "test",
"created_at": "2026-09-09T11:59:00+00:00",
"data": {
"transaction_type": "test",
"store": "ST322LJ223223K5FQ4NM3JWLK",
"message": "This is a test event from Paynance. No payment is associated with it."
}
}
4. Signature verification
Every request carries the Paynance-Signature header: the HMAC-SHA256 of the raw request
body, hex encoded, using your signing secret. There is no timestamp in it — idempotency is what
protects you against replays (section 5).
The header format is list-tolerant: it may carry several comma-separated values. Today we send one, but let your code handle both — that way a future double signature will not be a breaking change for you.
Verify the raw body, not the re-serialised JSON. This is the most common integration mistake: the framework parses the JSON first, and
json_encode()gives it back with different whitespace and different escaping than what came over the wire — while the signature is valid for the byte sequence we sent.
PHP
<?php
$secret = getenv('PAYNANCE_WEBHOOK_SECRET');
// The RAW body, before parsing.
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_PAYNANCE_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, $secret);
$valid = false;
foreach (explode(',', $header) as $candidate) {
// hash_equals() and not ===: the latter is vulnerable to timing attacks.
if (hash_equals($expected, trim($candidate))) {
$valid = true;
break;
}
}
if (! $valid) {
// 4xx, NEVER 2xx — see below. Recommended code: 401.
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// ... idempotency + queueing, then:
// 202: accepted, processing runs in the background. Any 2xx will do.
http_response_code(202);
In Laravel the raw body is $request->getContent() and the header is
$request->header('Paynance-Signature'). Exclude the route from CSRF protection.
Node (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.PAYNANCE_WEBHOOK_SECRET;
// The raw body is what we need, hence express.raw() instead of express.json().
app.post('/webhooks/paynance', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
const header = req.get('Paynance-Signature') || '';
const valid = header.split(',').some((candidate) => {
const given = Buffer.from(candidate.trim(), 'utf8');
const want = Buffer.from(expected, 'utf8');
// timingSafeEqual requires buffers of equal length.
return given.length === want.length && crypto.timingSafeEqual(given, want);
});
if (!valid) {
return res.sendStatus(401); // 4xx, not 2xx — recommended code: 401
}
const event = JSON.parse(req.body.toString('utf8'));
// ... idempotency + queueing
res.sendStatus(202); // accepted, processing runs in the background
});
On a signature failure return 4xx (recommended: 401), not 2xx
This is not a style suggestion, it is part of the contract. We retry on 4xx, so a temporary secret mismatch — typically after a rotation, while your server has not been updated yet — heals by itself. If you log the signature failure and return 2xx, the delivery is lost for good: it will look successful on our side and we will never retry it.
Any 4xx works for us, but we recommend 401 Unauthorized: in the delivery log the response
code is the only signal about why the event did not get through — 401 immediately
distinguishes a signature failure from a malformed payload (400) and from a processing failure
on your side (5xx).
5. Idempotency
A requirement, not a suggestion. The same event can arrive more than once — this is normal system behaviour, not a fault:
- a network hiccup (we sent it, the response was lost) causes a resend;
- with retries spanning 30 days this is statistically certain to happen;
- there is no replay protection at the signature level (no timestamp), so idempotency takes over that role.
The solution: store the event_id, and if you have seen it before, skip the processing — but
still return 2xx.
CREATE TABLE paynance_webhook_events (
event_id CHAR(36) PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL
);
try {
$db->prepare('INSERT INTO paynance_webhook_events (event_id, event_type, created_at) VALUES (?, ?, NOW())')
->execute([$event['event_id'], $event['event_type']]);
} catch (PDOException $e) {
// Duplicate: already processed. 2xx and done — the same 202 as on the normal path.
http_response_code(202);
exit;
}
event_id is a UUID v7, that is, time-ordered: the lexicographic order of the hex
representation matches the order of creation, so it behaves well as a primary key and old records
can be cleaned up as a contiguous range.
6. Retry policy
The schedule. If you do not return 2xx (or we cannot reach you within 5 seconds), we retry at the following points:
9 s → 18 s → 27 s → 2 m → 5 m → 10 m → 15 m → 30 m → 1 h → 2 h → 4 h → then every 8 hours
We keep retrying for as long as the event is in the delivery log: 30 days. There is no separate, shorter retry window — an undelivered event is retried on the schedule above until the log lets go of it at the end of the 30 day retention.
An event fails to arrive if, and only if, we could not hand it over once in 30 days.
Disabling does not destroy events. If your endpoint is disabled after a week of continuous failure (see below), that means only that we stop knocking — the events created meanwhile accumulate safely and go out in full once you switch it back on.
What you do have to prepare for: an event may arrive days after it was created. Your handler
should therefore not assume the event is fresh — created_at always tells you its real age, and
an order closed long ago should not be reopened automatically.
Do not build logic on ordering. In practice the order is preserved, but there is no guarantee:
for events arriving in parallel, one may become visible before the other. Reconstruct the state
from psp_reference / original_psp_reference and transaction_type, not from the order of
arrival.
Failing and disabled endpoints
| When | What happens |
|---|---|
| After 1 hour of continuous failure | We send an email to the partner contact address, then at most one a day until there is a success. |
| After 7 days of continuous failure | We disable the endpoint. We send no further events to it until you switch it back on. |
Disabling is time-based, not failure-count-based: the same outage means the same grace period at low and at high traffic alike.
Events are still created while the endpoint is disabled, they just don’t go out. When you switch it back on with the Activate button in the Paynance interface, the accumulated events all go out, in order of creation — nothing is discarded. At higher volumes that is a lot of calls at once, so it is worth sending a test event first and only reactivating a working endpoint.
Any successful delivery gives the endpoint a clean slate: both the failure counter and the warning timer are reset.
7. Secret rotation
The secret can be replaced at any time with the Rotate secret button. There is no overlap: from the moment of rotation every event — including the ones not yet delivered and the retries — goes out signed with the new secret. There is no previous secret and no key selection.
What you need to do:
- Rotate in the interface and copy the new secret.
- Update it on your server as soon as you can.
- Events sent in the meantime will fail signature verification. Answer them with 4xx — then we retry automatically and they get through after the update. With 2xx they are lost for good.
The gap this opens is the time between the rotation and your deploy. The 30 day retry horizon covers that comfortably — the loss is delay, not data.
8. Troubleshooting
The delivery log is on the Webshop settings page, under the webhook section. For every delivery you can see:
| Column | What it tells you |
|---|---|
| Created | When the event was created on our side. |
| Event | event_type. |
| pspReference | The processor reference of the operation — you can look the transaction up with it. |
| Status | Pending / Delivered / Failed. |
| Attempts | How many real HTTP calls were made. |
| Response | The HTTP response code of your endpoint. |
| Error | Connection error, timeout, or the error message from your endpoint. |
Manual redelivery. The Resend button on the log rows sends a specific event again. The attempt counter is reset and the row goes to the back of the queue. It only works on an active endpoint — if the endpoint is disabled, you need the Activate button first.
If we disabled your endpoint. A warning bar appears in the webhook section: since when, why (automatic or manual), what the last error was, and how many events are waiting for delivery. The recommended order:
- fix the endpoint (reachability, 2xx, response under 5 s);
- send a test event — synchronous, one attempt, it tells you right away whether it is fixed;
- only then Activate — this way you are not switching back blindly, and you know that the accumulated queue will actually go out.
Common mistakes
| Symptom | Cause |
|---|---|
| The signature never matches | You are verifying the re-serialised JSON, not the raw body (section 4). |
| Some events are processed twice | There is no idempotency on event_id (section 5). |
| Events “disappear silently” | You return 2xx on a signature failure (section 4). |
| The URL is not saved | Only https and publicly reachable addresses are accepted. |
| Lots of timeouts | You process the event before responding. Accept the event, answer with 2xx, work in the background. |