Il webhook di Monobank Acquiring: verifica ECDSA e perché conta il corpo grezzo della richiesta
Monobank firma i webhook con ECDSA invece che con un segreto HMAC condiviso. Ecco come funziona la verifica, come mettere in cache la chiave pubblica e i due errori che lasciano la verifica «funzionante» senza proteggere nulla.
La maggior parte dei gateway di pagamento ucraini firma i callback con un segreto condiviso: prendi la password del merchant, assembli una stringa secondo una formula documentata, ne calcoli l’hash e lo confronti. Monobank fa diversamente, e la differenza è facile da non notare, con conseguenze.
Monobank firma il webhook in modo asimmetrico: firma con una chiave privata e tu verifichi con la chiave PUBBLICA, che recuperi con una richiesta separata. Non esiste alcun segreto condiviso. È una sicurezza nettamente migliore — anche una violazione completa del tuo database non permette di falsificare un evento — ma richiede due cose che uno schema HMAC non richiede: la corretta gestione del corpo grezzo della richiesta e la cache della chiave pubblica.
Il contratto
- Firma
- L’header x-sign — ECDSA su SHA-256, codificato in base64
- Chiave di verifica
- La chiave pubblica del merchant, GET /api/merchant/pubkey (richiede il tuo X-Token)
- Cosa viene firmato
- I byte esatti del corpo della richiesta — non il risultato di un parsing e di una nuova serializzazione
- Dove arriva
- All’indirizzo che hai passato in webHookUrl alla creazione della fattura. L’API ufficiale non descrive alcun webhook globale del merchant.
- Associazione all’ordine
- merchantPaymInfo.reference — dove il tuo negozio inserisce il proprio identificativo d’ordine
- Importi
- In unità minime, non in unità intere di valuta
- Valuta
- Codice ISO-4217 numerico: 980 grivna, 840 dollaro, 978 euro
- Lo stato expired
- NON viene consegnato via webhook — lo monitora chi ha creato la fattura
Primo errore: fare il parsing del JSON prima di verificare la firma
La firma copre una sequenza precisa di byte. Se il tuo framework fa il parsing del corpo in un oggetto in automatico e poi lo serializzi di nuovo per verificare, stai verificando byte diversi. Ordine delle chiavi, spazi, rappresentazione dei numeri, escape dei caratteri non ASCII: qualunque di questi può cambiare, e cambia.
Il caso peggiore non è «non funziona nulla» ma «funziona sui dati di test e fallisce su quelli reali»: un payload semplice sopravvive per caso al giro di andata e ritorno, mentre uno con caratteri ucraini nel nome dell’acquirente o con un importo frazionario no.
La regola è una sola: leggi il corpo come testo grezzo, verifica la firma su quello e solo dopo fai il parsing. In un Route Handler di Next.js significa `await request.text()` prima di qualsiasi `request.json()` — la seconda chiamata comunque non è disponibile sulla stessa richiesta, quindi l’ordine non è una questione di stile.
Secondo errore: scaricare la chiave pubblica a ogni webhook
La chiave pubblica del merchant cambia raramente, mentre recuperarla costa un round trip verso Monobank e consuma il tuo rate limit. Farlo a ogni pagamento è un modo sicuro per andare in crisi sotto carico proprio quando i pagamenti sono numerosi.
Ma nemmeno metterla in cache per sempre va bene: la chiave può essere riemessa, e a quel punto una cache rigida significa che smetti di accettare pagamenti fino al deploy successivo.
Lo schema che funziona è cache più rilettura in caso di errore: prendi la chiave dalla cache e verifica; se la verifica fallisce, rileggi la chiave una volta sola e verifica di nuovo; se fallisce ancora, rifiuta. La rotazione della chiave si risolve da sola, mentre un attaccante con una firma errata ti costa al massimo una richiesta in più.
Una cosa ovvia che vale la pena dire perché spesso viene fatta male: la chiave pubblica NON è un segreto. Non cifrarla a riposo. Serve solo a verificare le firme e conoscerla non dà nulla a nessuno. Quello che va cifrato è l’X-Token che usi per recuperarla.
Una verifica che protegge davvero
- Verifica fallita — rifiuta la richiesta. Non registrarla nel log e andare avanti «per ogni evenienza»: un webhook senza firma valida non è un evento, è un estraneo che ti dice che sei stato pagato.
- Non è stato possibile recuperare la chiave, oppure non è stato possibile decifrare l’X-Token — rifiuta anche in questo caso. Fail closed: l’indisponibilità di un controllo non è una ragione per fidarsi di ciò che non è stato controllato.
- Confronta anche l’id del merchant con la configurazione del connettore — così un evento dall’account di qualcun altro non può finire nel tuo spazio di lavoro nemmeno con una firma valida.
- Rendi idempotente la scrittura del pagamento sull’id della fattura: la banca ha il diritto di consegnare lo stesso evento due volte, ed è un comportamento normale, non un guasto.
- Non salvare il numero della carta né il CVV. Importo, valuta, stato e id della fattura bastano per riconciliare — e ciò che non è nel tuo database non può trapelare.
Dove mettere un pagamento che non ha trovato un ordine
La firma è valida, l’importo c’è e il reference non corrisponde a nulla. La reazione più comune è scartare l’evento: restituire 200 e non scrivere nulla. È la scelta peggiore, perché i soldi sono già arrivati e ora non lasciano traccia nel tuo sistema.
La pratica da prevedere fin dall’inizio: salva un pagamento del genere come record a sé, senza ordine collegato, e mostralo in un registro dei pagamenti contrassegnato come non collegato. Un manager lo apre, vede importo e acquirente, e lo associa a un ordine esistente oppure crea un nuovo ordine a partire da esso. Sono dieci minuti di modellazione dei dati che eliminano un’intera classe di ticket del tipo «abbiamo pagato e non lo vedete».
Un callback firmato si autentica da solo
La tentazione di imporre «prima supera una verifica della connessione, poi accetta i pagamenti» è comprensibile ma qui ridondante: una firma ECDSA corretta più un id merchant coincidente autenticano l’evento in modo completo. Una verifica della connessione è utile come diagnostica — a quale account appartiene questo token? — ma renderla un prerequisito per i pagamenti reali mette un ostacolo tra un cliente e i suoi soldi.
Domande frequenti
In cosa la firma di Monobank è diversa da quella di LiqPay e WayForPay?
LiqPay e WayForPay usano un segreto condiviso — rispettivamente SHA-1 e HMAC-MD5, su una stringa assemblata come da documentazione. Monobank usa ECDSA asimmetrico: non c’è alcun segreto condiviso e si verifica con una chiave pubblica. Lo schema di un provider non va quindi mai copiato su un altro; ogni contratto va ricavato dalla documentazione ufficiale del provider stesso.
Si può configurare un unico webhook per l’intero account merchant?
L’API ufficiale non descrive nulla del genere. L’indirizzo viene passato nel campo webHookUrl di ogni richiesta di creazione fattura, quindi è compito del tuo negozio includerlo.
Perché lo stato expired non arriva mai?
Monobank non lo consegna via webhook. Se ti serve sapere delle fatture che non sono mai state pagate, deve monitorarne lo stato chi ha creato quelle fatture.
Gli importi arrivano in grivne intere?
No, in unità minime. 149.90 arriva come 14990. La valuta arriva come codice ISO-4217 numerico, non come stringa «UAH».
Il CRM può emettere un rimborso tramite questa integrazione?
Nella nostra implementazione no, deliberatamente. Il connettore accetta gli stati dei pagamenti; creare e annullare fatture, rimborsi e qualsiasi addebito restano a Monobank e al negozio. Un’integrazione che può muovere denaro richiede un altro livello di fiducia e un altro audit.
Un’integrazione già pronta invece della tua
Obriym CRM accetta i webhook firmati di Monobank, LiqPay, WayForPay e Hutko in un unico registro dei pagamenti — con idempotenza, riconciliazione degli ordini e una notifica al tuo team.
Provalo gratis