Merchant Webhook integrációs útmutató

🇬🇧 English version: Merchant_Webhook_API.en.md

Kimenő webhook a Paynance platformról: a webshopod fizetési eseményeit elküldjük egy általad megadott URL-re, aláírva, újrapróbálkozással, kézbesítési naplóval.

Gépi olvasható séma: Merchant_Webhook_API.hu.openapi.json (OpenAPI 3.0.3) — kliens- és típusgeneráláshoz, Swagger UI / Redoc megjelenítéshez. Élő próbához aláírás-számítással: Merchant_Webhook_API.hu.postman_collection.json.


1. Áttekintés

A vásárló böngészőjéből való visszatérés nem megbízható jelzés: a vásárló bezárhatja a fület, a hálózat elszakadhat, a fizetés pedig órákkal később is állapotot változtathat (capture, refund, chargeback). A webhook a szerver-szerver csatorna, amiből biztosan megtudod, mi történt.

Mikor küldünk. Amikor egy webshopos (VPOS) fizetés állapota változik: authorizáció, authorizáció-hiba, capture, cancel, refund, chargeback. POS/terminál tranzakciókról nem küldünk webhookot.

Mit várunk tőled.

Válaszkód 2xx, ha átvetted az eseményt — javasolt: 202 Accepted
Válaszidő gyorsan (5 s a timeoutunk) — a feldolgozást tedd háttérbe
Idempotencia kötelező, event_id alapján (lásd 5. pont)
Aláírás-hiba 4xx, soha ne 2xx (lásd 4. pont) — javasolt: 401 Unauthorized

A konkrét kódot nem kötjük meg, csak az osztályát. Bármely 2xx átvételnek számít, bármely 4xx és 5xx újrapróbálkozást vált ki. A javaslat a két konkrét kódra attól van, hogy pontosan azt mondják, ami történik — és mert a saját logjaidban is ezekből fogod látni, mi ment félre:

  • 202 Accepted — átvetted az eseményt, a feldolgozás háttérben zajlik. Ez fedi a valóságot: 5 másodperc alatt úgysem fejezed be, a 200 OK pedig azt sugallná, hogy kész vagy. (A 200 természetesen ugyanúgy jó nekünk.)
  • 401 Unauthorized — az aláírás nem stimmel. Egyértelműen elkülöníti az „ismeretlen küldő” esetet a hibás payloadtól (400) vagy a te feldolgozási hibádtól (5xx), így a rotáció utáni átmeneti eltérés azonnal felismerhető a logban.

A kérés minden esetben:

POST <a te URL-ed>
Content-Type: application/json
Paynance-Signature: <hex HMAC-SHA256>

2. Beállítás

A Paynance felületen: Webshop beállítások → Webhook (fizetési értesítések).

  1. Webhook URL — csak https, publikusan elérhető címen. Privát IP-re vagy http-re mutató URL-t nem fogadunk el.
  2. Aláírási titok — a beállítás mentésekor automatikusan generáljuk. Maszkolva látszik (whsec_550d2a2b…); a Titok felfedése gombbal bármikor megnézheted a teljes értéket. Ha elveszik a szerveredről, nem kell rotálni: itt újra kiolvasható.
  3. Teszt-esemény küldése — azonnal, szinkron küld egy kérést a végpontodra, és a válaszkódot a helyszínen visszamutatja.
  4. Kézbesítési napló — ugyanezen az oldalon: mely események mikor és milyen válaszkóddal mentek ki, plusz kézi újraküldés.

Két dolgot érdemes tudni a teszt-eseményről:

  • event_type értéke test, nem szintetikus fizetési esemény — tehát a kódod ne fizetési eseményt várjon tőle. Az event_id viszont valódi UUID v7, hogy az idempotencia-tárolód pontosan úgy viselkedjen, ahogy itt le van írva.
  • A naplóba nem kerül be. Az eredmény ott és akkor látszik a képernyőn; visszamenőleg nem kereshető, és a végpont hibaállapotát sem befolyásolja.

3. Payload

Minden payload ugyanezt a burkot használja:

Mező Típus Leírás
version int Payload séma verzió. Új mező bekerülhet emelés nélkül, átnevezés nem.
event_id string (UUID v7) Az esemény publikus azonosítója. Erre épül az idempotencia.
event_type string A mi verziózott eseménynevünk, lásd a táblát lentebb.
created_at string (ISO 8601) Mikor keletkezett az esemény nálunk.
data object Az esemény tartalma.

A data objektum mezői:

Mező Típus Leírás
psp_reference string Az adott művelet feldolgozói referenciája. Egy fizetés capture-je és refundja külön psp_reference-t kap.
original_psp_reference string | null A művelet mögötti fizetés referenciája. payment.authorised/payment.failed esetén null.
merchant_reference string A te rendelés-azonosítód, amit a session létrehozásakor küldtél.
transaction_type string payment, capture, cancellation, refund, chargeback vagy test.
success bool Az adott művelet sikeres volt-e.
amount object | null { "value": <minor unit>, "currency": "HUF" } — a value a legkisebb pénzegységben.
payment_method string | null Pl. visa, mc, applepay.
reason string | null Hiba vagy elutasítás oka, ha van.
event_date string | null A művelet időpontja a fizetésfeldolgozásban.
shopper_email string | null A vásárló e-mail címe, ha megadta.
store string A Paynance store azonosító.

Eseménytípusok

event_type Mikor
payment.authorised A fizetés sikeresen authorizálódott.
payment.failed Az authorizáció elhasalt (elutasítás, hibás kártya, 3DS-hiba).
payment.captured Az összeg terhelése megtörtént. Részleges capture-nél több esemény jön.
payment.cancelled Az authorizáció visszavonva, terhelés nélkül.
payment.refunded Visszatérítés. Részleges refundnál több esemény jön.
payment.chargeback A vásárló bankja visszahívta az összeget.
test A beállítófelület teszt-gombja.

Példák

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 — figyeld meg, hogy a psp_reference a capture referenciája, a fizetés a original_psp_reference-ben van. Egy részleges capture-sorozat minden tagja külön eseményt kap.

{
    "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 — a beállítófelület teszt-gombja. Nincs mögötte fizetés, ezért a data szűkebb.

{
    "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. Aláírás-ellenőrzés

Minden kérésen ott van a Paynance-Signature header: a nyers request body HMAC-SHA256-a, hex kódolva, az aláírási titkoddal. Timestamp nincs benne — a replay ellen az idempotencia véd (5. pont).

A header formátuma listás-toleráns: vesszővel elválasztva több érték is állhat benne. Ma egy értéket küldünk, de a példakód mindkettőt kezelje — így egy későbbi dupla aláírás nem lesz breaking change nálad.

A nyers bodyt ellenőrizd, ne az újra-szerializált JSON-t. Ez a leggyakoribb integrációs hiba: a framework előbb parse-olja a JSON-t, a json_encode() pedig más szóközökkel és más escape-eléssel adja vissza, mint ami a huzalon jött — az aláírás pedig arra a byte-sorozatra érvényes, amit küldtünk.

PHP

<?php

$secret = getenv('PAYNANCE_WEBHOOK_SECRET');

// A NYERS body, parse előtt.
$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() és nem ===: az utóbbi időzítéses támadásra sebezhető.
    if (hash_equals($expected, trim($candidate))) {
        $valid = true;
        break;
    }
}

if (! $valid) {
    // 4xx, SOHA nem 2xx — lásd lentebb. Javasolt kód: 401.
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);

// ... idempotencia + sorbaállítás, majd:
// 202: átvettük, a feldolgozás háttérben megy. Bármely 2xx megfelel.
http_response_code(202);

Laravelben a nyers body $request->getContent(), a header $request->header('Paynance-Signature'). A route-ot vedd ki a CSRF-védelem alól.

Node (Express)

const crypto = require('crypto');
const express = require('express');

const app = express();
const secret = process.env.PAYNANCE_WEBHOOK_SECRET;

// A nyers bodyra van szükség, ezért express.raw() és nem 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 azonos hosszú buffert vár.
        return given.length === want.length && crypto.timingSafeEqual(given, want);
    });

    if (!valid) {
        return res.sendStatus(401); // 4xx, nem 2xx — javasolt kód: 401
    }

    const event = JSON.parse(req.body.toString('utf8'));

    // ... idempotencia + sorbaállítás
    res.sendStatus(202); // átvettük, a feldolgozás háttérben megy
});

Aláírás-hibánál 4xx-et adj vissza (javasolt: 401), ne 2xx-et

Ez nem stílusajánlás, hanem a szerződés része. A 4xx-re újrapróbálunk, tehát egy átmeneti titok-eltérés — tipikusan rotáció után, amíg a szervereden nem frissítettél — magától helyreáll. Ha aláírás-hibára logolsz és 2xx-et adsz, a kézbesítés véglegesen elveszik: nálunk sikeresnek fog látszani, és soha nem próbáljuk újra.

Bármely 4xx jó nekünk, de 401 Unauthorized-ot javaslunk: a kézbesítési naplóban a válaszkód az egyetlen jelzés arról, miért nem ment át az esemény — a 401 azonnal megkülönbözteti az aláírás-hibát a hibás payloadtól (400) és a te feldolgozási hibádtól (5xx).


5. Idempotencia

Követelmény, nem javaslat. Ugyanaz az esemény többször is megérkezhet — ez a rendszer normál működése, nem hiba:

  • egy hálózati félrecsúszás (elküldtük, a válasz elveszett) újraküldést okoz;
  • a 30 napig tartó újrapróbálkozás mellett ez statisztikailag biztosan elő fog fordulni;
  • aláírás szinten nincs replay-védelem (nincs timestamp), az idempotencia veszi át a szerepét.

A megoldás: tárold el az event_id-t, és ha már láttad, hagyd ki a feldolgozást — de továbbra is adj vissza 2xx-et.

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) {
    // Duplikátum: már feldolgoztuk. 2xx, és kész — ugyanaz a 202, mint a normál ágon.
    http_response_code(202);
    exit;
}

A event_id UUID v7, azaz időrendezett: a hex reprezentáció lexikografikus sorrendje megegyezik a keletkezési sorrenddel, tehát primary key-ként is jól viselkedik, és a régi rekordokat összefüggő tartományként lehet takarítani.


6. Retry-politika

A menetrend. Ha nem 2xx-et adsz vissza (vagy nem érünk el 5 másodpercen belül), a következő időpontokban próbálkozunk újra:

9 s → 18 s → 27 s → 2 m → 5 m → 10 m → 15 m → 30 m → 1 h → 2 h → 4 h → majd 8 óránként

Addig próbálkozunk, amíg az esemény a kézbesítési naplóban van: 30 napig. Nincs külön, rövidebb újrapróbálkozási ablak — egy ki nem kézbesített eseményt a fenti menetrend szerint addig küldünk újra, amíg a napló meg nem szabadul tőle a 30 napos megőrzési idő végén.

Egy esemény akkor és csak akkor nem érkezik meg, ha 30 napon át egyszer sem tudtuk átadni.

A letiltás nem semmisít meg eseményt. Ha a végpontod egy hét folyamatos hiba után letiltásra kerül (lásd lejjebb), az csak annyit jelent, hogy nem kopogtatunk tovább — a közben keletkező események rendben felgyűlnek, és a visszakapcsolás után hiánytalanul kimennek.

Amire viszont neked kell felkészülnöd: egy esemény napokkal a keletkezése után is megérkezhet. A feldolgozásod ezért ne tételezze fel, hogy az esemény friss — a created_at mezőből mindig látod a tényleges kort, és egy rég lezárt rendelést ne nyisson újra automatikusan.

A sorrendre ne épüljön logika. A gyakorlatban a sorrend megmarad, garancia nincs rá: párhuzamosan beérkező eseményeknél az egyik előbb válhat láthatóvá, mint a másik. Az állapotot a psp_reference / original_psp_reference és a transaction_type alapján rakd össze, ne a beérkezési sorrendből.

Hibázó és letiltott végpont

Mikor Mi történik
1 óra folyamatos hiba után E-mailt küldünk a partneri kapcsolattartói címre, majd naponta legfeljebb egyet, amíg nincs siker.
7 nap folyamatos hiba után Letiltjuk a végpontot. Nem küldünk rá további eseményt, amíg vissza nem kapcsolod.

A letiltás időalapú, nem hibaszám-alapú: ugyanaz a kiesés ugyanannyi türelmi időt jelent alacsony és nagy forgalom mellett is.

Letiltás alatt az események keletkeznek, csak nem mennek ki. Amikor a Paynance felületen az Aktiválás gombbal visszakapcsolsz, a felgyűlt események mind kimennek, keletkezési sorrendben — semmit nem dobunk el. Nagyobb forgalomnál ez egyszerre sok hívást jelent, ezért érdemes előbb teszt eseményt küldeni, és csak működő végponttal visszakapcsolni.

Bármelyik sikeres kézbesítés tiszta lappal indítja a végpontot: a hibaszámláló és a figyelmeztetés-időzítő is nullázódik.


7. Titok-rotáció

A Titok rotálása gombbal bármikor cserélhető. Átfedés nincs: a rotáció pillanatától minden esemény — a még ki nem kézbesítettek és az újrapróbálkozások is — az új titokkal jön ki. Nincs előző titok, nincs kulcsválasztás.

Amit tenned kell:

  1. Rotálj a felületen, és másold ki az új titkot.
  2. Frissítsd a szervereden, amint tudod.
  3. A közben kiküldött események aláírás-hibát adnak. Adj rájuk 4xx-et — akkor automatikusan újrapróbáljuk, és a frissítés után átmennek. 2xx-szel ezek véglegesen elvesznek.

A rés, amit ez nyit, a rotáció és a te deployod közti idő. A 30 napos újrapróbálkozás ezt bőven elfedi — a veszteség késés, nem adat.


8. Hibakeresés

A kézbesítési napló a Webshop beállítások oldalon van, a webhook szekció alatt. Minden kézbesítésről látszik:

Oszlop Mit mond
Keletkezett Mikor jött létre az esemény nálunk.
Esemény event_type.
pspReference Az adott művelet feldolgozói referenciája — ezzel kereshető vissza a tranzakció.
Állapot Várakozik / Kézbesítve / Sikertelen.
Kísérletek Hány valódi HTTP-hívás történt.
Válasz A végpontod HTTP válaszkódja.
Hiba Kapcsolódási hiba, timeout, vagy a végpontod hibaüzenete.

Kézi újraküldés. A napló sorain lévő Újraküldés gombbal egy konkrét esemény újra kiküldhető. A kísérletszámláló nullázódik, és a sor a lista végére kerül. Csak aktív végponton működik — ha a végpont le van tiltva, előbb az Aktiválás gomb kell.

Ha letiltottuk a végpontod. A webhook szekcióban egy figyelmeztető sáv jelenik meg: mióta, miért (automatikus vagy kézi), mi volt az utolsó hiba, és hány esemény vár kézbesítésre. A javasolt sorrend:

  1. javítsd a végpontot (elérhetőség, 2xx, 5 s alatti válasz);
  2. küldj teszt-eseményt — szinkron, egy kísérlet, azonnal megmondja, jó-e már;
  3. csak ezután Aktiválás — így nem vakon kapcsolsz vissza, és tudod, hogy a felgyűlt sor valóban ki fog menni.

Gyakori hibák

Tünet Ok
Az aláírás soha nem egyezik Az újra-szerializált JSON-t ellenőrzöd, nem a nyers bodyt (4. pont).
Egyes események duplán dolgozódnak fel Nincs event_id szerinti idempotencia (5. pont).
Az események „csendben elvesznek” Aláírás-hibára 2xx-et adsz vissza (4. pont).
Az URL nem mentődik Csak https és publikusan elérhető cím fogadható el.
Sok timeout A feldolgozást a válasz előtt végzed. Vedd át az eseményt, válaszolj 2xx-szel, dolgozz háttérben.