Obriym CRMObriym CRMCustomer flow workspace
AIFeaturesPricingRoadmapIntegrationsBlog
Sign inGet started
AIFeaturesPricingRoadmapIntegrationsBlog
Blog
Write-ups16 August 2026

The Monobank Acquiring webhook: ECDSA verification and why the raw request body matters

Monobank signs webhooks with ECDSA rather than a shared HMAC secret. Here is how the verification works, how to cache the public key, and the two mistakes that leave verification "working" while protecting nothing.

Most Ukrainian payment gateways sign their callbacks with a shared secret: take your merchant password, assemble a string by a documented formula, hash it, compare. Monobank does it differently, and the difference is easy to miss with consequences.

Monobank signs the webhook asymmetrically: it signs with a private key and you verify with the PUBLIC key, which you fetch in a separate request. There is no shared secret at all. That is strictly better security — even a full breach of your database grants no ability to forge an event — but it demands two things an HMAC scheme does not: correct handling of the raw request body, and caching of the public key.

The contract

Signature
The x-sign header — ECDSA over SHA-256, base64-encoded
Verification key
The merchant public key, GET /api/merchant/pubkey (requires your X-Token)
What is signed
The exact bytes of the request body — not the result of parsing and re-serialising it
Where it arrives
The address you passed in webHookUrl when creating the invoice. The official API describes no global merchant webhook.
Order matching
merchantPaymInfo.reference — where your store puts its own order identifier
Amounts
In minor units, not in whole currency units
Currency
Numeric ISO-4217 code: 980 hryvnia, 840 dollar, 978 euro
The expired status
NOT delivered by webhook — whoever created the invoice tracks that

Mistake one: parsing the JSON before verifying the signature

The signature covers a specific sequence of bytes. If your framework parses the body into an object automatically and you then serialise it back to verify, you are verifying different bytes. Key order, whitespace, number representation, non-ASCII escaping: any of these can differ, and they do.

The worst outcome here is not "nothing works" but "works on test data and fails on real data": a simple payload happens to survive the round trip, while one carrying Ukrainian characters in the buyer's name or a fractional amount does not.

There is one rule: read the body as raw text, verify the signature against that, and only then parse. In a Next.js Route Handler that means `await request.text()` before any `request.json()` — the second call is unavailable on the same request anyway, so the order here is not a matter of style.

Mistake two: fetching the public key on every webhook

The merchant public key changes rarely, while fetching it costs a round trip to Monobank and consumes your rate limit. Doing it per payment is a reliable way to fail under load exactly when payments are plentiful.

But caching it forever is wrong too: the key can be reissued, and then a hard cache means you stop accepting payments until the next deploy.

The working scheme is cache plus refresh-on-failure: take the key from cache and verify; if verification fails, re-read the key once and verify again; if it still fails, reject. Key rotation then heals itself, while an attacker with a bad signature costs you at most one extra request.

One obvious thing worth stating because it is often done wrong: the public key is NOT a secret. Do not encrypt it at rest. It exists only to verify signatures, and knowing it grants nobody anything. What belongs encrypted is the X-Token you use to fetch it.

Verification that actually protects

  • Verification failed — reject the request. Do not log it and carry on "just in case": a webhook without a valid signature is not an event, it is a stranger telling you that you were paid.
  • The key could not be fetched, or the X-Token could not be decrypted — reject as well. Fail closed: the unavailability of a check is no reason to trust the unchecked.
  • Compare the merchant id against the adapter's configuration too — then an event from someone else's account cannot land in your workspace even with a valid signature.
  • Make the payment write idempotent on the invoice id: the bank is entitled to deliver the same event twice, and that is normal behaviour rather than a fault.
  • Do not store the card number or CVV. Amount, currency, status and invoice id are enough to reconcile — and what is not in your database cannot leak.

Where to put a payment that found no order

The signature checks out, the amount is there, and the reference matches nothing. The most common reaction is to discard the event: return 200, write nothing. That is the worst available choice, because the money has already arrived and now leaves no trace in your system.

The practice worth building in from the start: store such a payment as its own record with no order attached and show it in a payments journal marked unlinked. A manager opens it, sees the amount and the buyer, and either attaches it to an existing order or creates a new one from it. That is ten minutes of data modelling which removes an entire class of "we paid and you cannot see it" tickets.

A signed callback authenticates itself

The temptation to require "pass a test connection first, then accept payments" is understandable but redundant here: a correct ECDSA signature plus a matching merchant id already authenticates the event completely. A connection test is useful as diagnostics — which account does this token belong to? — but making it a precondition for real payments puts an obstacle between a customer and their money.

Frequently asked

How does Monobank's signature differ from LiqPay and WayForPay?

LiqPay and WayForPay use a shared secret — SHA-1 and HMAC-MD5 respectively, over a documented assembled string. Monobank uses asymmetric ECDSA: there is no shared secret and you verify with a public key. So one provider's scheme must never be copied onto another; each contract comes from that provider's own official documentation.

Can one webhook be configured for the whole merchant account?

The official API describes no such thing. The address is passed in the webHookUrl field of every invoice-create request, so it is your store's job to include it.

Why does the expired status never arrive?

Monobank does not deliver it by webhook. If you need to know about invoices that were never paid, whoever created those invoices has to track their state.

Do amounts arrive in whole hryvnia?

No, in minor units. 149.90 arrives as 14990. The currency comes as a numeric ISO-4217 code, not the string "UAH".

Can the CRM issue a refund through this integration?

In our implementation no, deliberately. The adapter accepts payment statuses; creating and cancelling invoices, refunds and any charges stay with Monobank and the store. An integration that can move money needs a different level of trust and a different audit.

A ready integration instead of your own

Obriym CRM accepts signed webhooks from Monobank, LiqPay, WayForPay and Hutko into one payments journal — with idempotency, order reconciliation and a notification to your team.

Try it free

Integrations mentioned

Monobank

Beta

The buyer pays an invoice, the CRM receives a signed confirmation from Monobank, puts the payment on the order and tells your team at once. No reconciling statements by hand.

Learn more

LiqPay

Beta

A payment confirmed on LiqPay's side raises one team alert and updates a matching order in the CRM.

Learn more

WayForPay

Available

The CRM verifies each payment result, alerts your team, and links the payment to an existing order. Without a match, it remains in a clear reconciliation queue.

Learn more

Hutko

Available

After every payment, the CRM records the result, alerts your team, and either attaches the payment to an existing order or keeps it in a clear reconciliation queue.

Learn more

Beta: Working and ready to connect. Not yet verified on a live account — expect the odd rough edge, which we fix fast.

Obriym CRMObriym CRMCustomer flow workspace

Obriym CRM is a focused workspace for sales teams and e-commerce operations. Leads, deals, orders, and customer retention in one place.

Product by OBRIYM

  • crm@obriym.com
  • OBRIYM
  • Serhii Oberemchuk

Individual Entrepreneur Oberemchuk Serhii Oleksandrovych · State reg. 178752761226

Product

  • AI
  • Features
  • Pricing
  • Compare
  • Blog
  • Roadmap
  • Integrations
  • API Reference

Company

  • About OBRIYM
  • Founder
  • Contact
  • Roadmap

Developers

  • For developers
  • OpenAPI spec
  • JS Widget
  • Lead intake API
  • Orders API

©2026Obriym CRM by OBRIYM. Built for production sales and e-commerce teams.

Terms of UseTerms of ServicePayment & RefundsPrivacy PolicySubprocessorsData ProcessingLegal detailsData DeletionPricingAPI Docs