Callbacks (notifications)
When a transaction reaches a final status, MoyaPay sends a POST request to your server.
Where are they sent?
To the callback URLs registered on your account by MoyaPay:
- one URL for payins;
- one URL for payouts.
To create or change them, contact your MoyaPay representative.
Notification content
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Signature | HMAC-SHA256 signature of the body, hex-encoded |
Body:
{
"code": 200,
"status": "SUCCESS",
"message": "Your transaction is SUCCESS",
"transactionID": "ORDER-2026-000123",
"amount": "1000",
"date": "2026-10-04T10:15:30.000Z",
"motif": ""
}
| Field | Description |
|---|---|
status | Final status: SUCCESS or FAILED |
transactionID | Your merchant_transaction_id |
amount | Transaction amount, as a string |
date | Creation date of the transaction (ISO 8601) |
motif | Failure reason when the operator provides one, otherwise an empty string |
code, message | Informational values. Do not base your logic on them |
Verify the signature
Compute the HMAC-SHA256 of the request's raw body with your signing secret, then compare it with X-Signature. Reject the notification if they do not match.
The signature covers the exact bytes sent by MoyaPay. If you parse the JSON and re-encode it before computing the signature, field order or formatting may change and the check will fail. Compute it on the body as received.
- Node.js
- PHP
- Python (Flask)
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post(
'/api/moyapay/notify',
express.raw({ type: 'application/json' }), // raw body (Buffer)
(req, res) => {
const received = req.get('X-Signature') || '';
const expected = crypto
.createHmac('sha256', process.env.MOYAPAY_CALLBACK_SECRET)
.update(req.body)
.digest('hex');
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Idempotent processing: event.transactionID, event.status
res.sendStatus(200);
},
);
<?php
$raw = file_get_contents('php://input'); // raw body
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('MOYAPAY_CALLBACK_SECRET'));
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Idempotent processing: $event['transactionID'], $event['status']
http_response_code(200);
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/api/moyapay/notify")
def notify():
raw = request.get_data() # raw body (bytes)
received = request.headers.get("X-Signature", "")
expected = hmac.new(
os.environ["MOYAPAY_CALLBACK_SECRET"].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json(force=True)
# Idempotent processing: event["transactionID"], event["status"]
return "", 200
Respond to the notification
- Reply with a
2xxstatus to acknowledge receipt. - Reply within 2 seconds: after that, MoyaPay gives up on the call. Acknowledge first, process afterwards (queue, background task).
Reliability: what to plan for
The notification is sent at the moment the final status is recorded. There is no automatic retry schedule if your server is down or too slow to answer at that moment.
- If you have not received a callback for a transaction after a reasonable delay, query its status.
- Make your processing idempotent: the same transaction may be notified more than once, and a
FAILEDcan be followed by aSUCCESS(see Statuses). - Keep your signing secret server-side and never publish it (code repository, mobile app, etc.).