Crypto Payment Webhook Events: A Guide for Invoices and Payouts
Every MercanPay crypto payment webhook event explained: invoice.paid, invoice.partial, withdrawal.completed and more, with payload fields and a handler.

Crypto payment webhook events are how MercanPay tells your server what just happened. When an invoice is paid, underpaid or expires, or when a payout completes, your server receives a signed POST request. This guide puts every event in one place: when it fires, which fields its payload carries, and how to handle it in code.
Where webhooks are sent
Events can go to two addresses:
- Settings → Webhook URL: the default address for every event on your account.
- The invoice's
callback_url: if you set it when creating an invoice, that invoice's events are sent there as well.
When both are set, the same event goes to both and carries the same event id (id) each time, so deduplicating by id is straightforward.
Webhooks are only sent after the state change behind them has been committed. If you receive "paid", the invoice really is paid in the system.
The shared envelope
Every event uses the same envelope:
{
"id": "3f1c2b9a-…",
"type": "invoice.paid",
"created_at": "2026-11-05T10:34:12.000Z",
"data": { }
}
id: the unique event id. Use it to drop duplicates.type: the event type.created_at: when the event occurred (UTC).data: the content, which depends on the event.
The request headers never change: Content-Type: application/json, User-Agent: MercanPay-Webhook/1.0, and X-MercanPay-Signature: t=…,v1=… carrying the signature. Verification is covered in how to verify webhook signatures.
Invoice events
| Event | Sent when | What it means for the order |
|---|---|---|
invoice.paid |
The invoice is fully paid | Fulfil the order |
invoice.overpaid |
The invoice is overpaid | Fulfil and decide what to do with the excess |
invoice.partial |
An underpayment arrived | Wait and don't deliver yet |
invoice.expired |
The invoice expired unpaid | Close the order or offer a new invoice |
invoice.cancelled |
The invoice was cancelled | Close the order |
Payment-driven events (paid, overpaid, partial) carry two objects in data:
data.invoice: the invoice's current state, includingid,order_id,status,token,amount,paid_amount,remaining_amount,overpaid_amount,price_amountandmetadata.data.payment: the payment that triggered the event, withtxid,from(the sender address),amountandlate.
If late is true, the payment arrived after the invoice expired. Lateness is judged by the time of the block the payment landed in, so a customer who pays in the final seconds isn't marked late because of confirmation time. When a USD-priced TRX invoice is paid late, the amount is re-priced at the current rate and the details come in data.payment.repriced (previous_amount, amount, rate_usd). More in handling underpayments, overpayments and late payments.
invoice.expired and invoice.cancelled only carry data.invoice, since no payment triggers them.
Invoices accepted within your tolerance
If you've enabled the underpayment tolerance in the panel, small shortfalls within your chosen percentage are reported as invoice.paid. The invoice's underpaid_amount field then shows the missing amount you accepted, so account for it in your records.
Withdrawal events
| Event | Sent when |
|---|---|
withdrawal.completed |
The withdrawal completed on-chain |
withdrawal.failed |
The withdrawal failed and the amount was returned to your balance |
withdrawal.rejected |
The withdrawal was rejected and the amount was returned to your balance |
Withdrawal events carry a data.withdrawal object with id, idempotency_key, status, token, amount, fee, to_address, txid, error, created_at and completed_at. If you create payouts through the API, the idempotency_key you used lets you match the event to your own record. The whole payout flow is in the crypto balance withdrawal process.
The test event: test.ping
The "Send test" button on the Webhooks page of the panel sends a test.ping event to your Webhook URL right away. It doesn't represent a real transaction. Its purpose is to confirm that your endpoint is reachable and that signature verification works. Test events aren't retried automatically.
Delivery history and resending
The Webhooks page keeps a record of every event sent. For each delivery you can see:
- the event type and the URL it went to
- its status (delivered, pending, failed) and the number of attempts
- the HTTP code your server returned and the response time
- the first part of your server's response body
If a delivery failed, fix the cause and use "Resend" to deliver it again. You can also export the delivery history as CSV, Excel or PDF.
Common reasons for failed deliveries
- No answer within 10 seconds. Queue slow work and return 2xx immediately.
- An endpoint that redirects. Webhook requests don't follow redirects, so a URL that redirects from
httptohttpsor to a trailing-slash version counts as a failure. Enter the final URL directly. - An authentication wall. Your webhook endpoint shouldn't require a login or password, because the signature provides the security.
- A stale signing secret. If you rotated the
whsec_…secret in Settings, update it on your server too.
Requests that don't get a 2xx are retried at growing intervals. The schedule and what it means for your code are covered in webhook retries and idempotent handling.
Code example: routing by event type
This Python example verifies the signature with the official library's verify_webhook and routes each event type to its own handler:
import json
import os
from flask import Flask, request, abort
from mercanpay import verify_webhook
app = Flask(__name__)
SECRET = os.environ["MERCANPAY_WEBHOOK_SECRET"]
@app.post("/mercanpay/webhook")
def webhook():
body = request.get_data()
if not verify_webhook(SECRET, body, request.headers.get("X-MercanPay-Signature", "")):
abort(401)
event = json.loads(body)
if already_processed(event["id"]):
return "", 200
kind, data = event["type"], event["data"]
if kind in ("invoice.paid", "invoice.overpaid"):
fulfil_order(data["invoice"]["order_id"], data["invoice"]["paid_amount"], data["payment"]["late"])
elif kind == "invoice.partial":
mark_partial(data["invoice"]["order_id"], data["invoice"]["remaining_amount"])
elif kind in ("invoice.expired", "invoice.cancelled"):
close_order(data["invoice"]["order_id"])
elif kind == "withdrawal.completed":
mark_payout_sent(data["withdrawal"]["idempotency_key"], data["withdrawal"]["txid"])
elif kind in ("withdrawal.failed", "withdrawal.rejected"):
mark_payout_failed(data["withdrawal"]["idempotency_key"], data["withdrawal"]["error"])
remember(event["id"])
return "", 200
Return 2xx for event types you don't recognise, too. That way your integration won't trigger pointless retries if new event types are added later. For PHP, see the PHP integration guide.
A short pre-launch checklist
Before wiring your webhook endpoint to real orders, work through these steps:
- Use HTTPS and enter the final URL in Settings, with no redirects.
- Try "Send test". Make sure the delivery history shows a 2xx response.
- Watch verification reject a bad request. Check that a request signed with the wrong secret gets a 401.
- Drop duplicates. Confirm that an event arriving twice doesn't process the order twice.
- Plan for every event type. Decide what happens on
invoice.partial,invoice.overpaidandinvoice.expired, not only oninvoice.paid. - Test end to end with a small real payment.
FAQ
Can I poll the API instead of using webhooks?
Yes. GET /v1/invoices/{id} or GET /v1/invoices/by-order/{order_id} always returns the invoice's current state. The most robust setup uses webhooks for real-time updates and API queries as a fallback check.
Do events arrive in order?
Don't rely on ordering. A retry can arrive after a later event, for example. Base your decisions on data.invoice.status.
What if I have no Webhook URL?
No events are sent. Payments are still credited to your balance, and you can follow them in the panel and through email notifications.
Do withdrawal events cover panel withdrawals too?
Yes. withdrawal.* events are sent for withdrawals you make in the panel as well.
Get started with MercanPay
To accept payments on a platform that reports every state change with a signed event, open your merchant account. All fields are listed in the documentation.


