Merchant Webhook integration guide

🇭🇺 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, whereas 200 OK would suggest that you are done. (200 is, 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).

  1. Webhook URL — https only, on a publicly reachable address. We do not accept URLs pointing to private IPs or to http.
  2. 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.
  3. Send test event — sends a request to your endpoint immediately and synchronously, and shows the response code on the spot.
  4. 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_type is test, not a synthetic payment event — so your code must not expect a payment event from it. The event_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
});

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:

  1. Rotate in the interface and copy the new secret.
  2. Update it on your server as soon as you can.
  3. 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:

  1. fix the endpoint (reachability, 2xx, response under 5 s);
  2. send a test event — synchronous, one attempt, it tells you right away whether it is fixed;
  3. 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.