Verifying MVP Payments webhooks
MVP Payments notifies your server about payment outcomes with signed
webhooks. One event is always sent — payment.finality, when a payment
reaches INITIATED or FAILED. Optionally (ask us to enable it), you can
also receive payment.status.changed on every public status transition.
This guide shows how to verify that a webhook genuinely came from MVP Payments and hasn’t been tampered with or replayed.
What a delivery looks like
Every delivery is an HTTPS POST to your configured webhook URL:
| Header | Value |
|---|---|
content-type | application/json |
x-mvp-signature | an RS256 JWT signed with your dedicated key (see below) |
x-mvp-id | the payment’s mvpId |
x-mvp-attempt | delivery attempt, 1–5 |
x-mvp-event | payment.finality or payment.status.changed |
The body is a JSON snapshot of the payment (a fixed, documented field set —
amount, currency, status, beneficiary, fee, timestamps and so on — see
WebhookPayload). The same fields,
with the same values, are always available from GET /v1/payments/{mvpId} —
the webhook and the API never disagree.
Acknowledge with any 2xx within 8 seconds. Anything else (including a
timeout) is retried 5 times in total, spaced 30 / 60 / 120 / 240 seconds.
After the fifth failure the delivery is parked and visible in your dashboard’s
delivery history. Deliveries are at least once: design your handler to be
idempotent on (mvpId, x-mvp-event).
Your signing key
Each client has its own RSA-2048 signing key. The private key lives in a
hardware-backed KMS and never leaves it; the public key (PEM) is shown in
your dashboard. When the key is rotated you’ll see a new PEM and a new
version there — fetch it again rather than hard-coding it forever.
What to verify
The x-mvp-signature JWT’s claims are the payload plus:
| Claim | Check |
|---|---|
| (signature) | verifies as RS256 against your published public key |
iss | exactly mvp-payments.com |
aud | exactly your webhook URL |
sub | equals the payload’s mvpId and the x-mvp-id header |
exp | in the future (tokens live 5 minutes from signing) |
jti | never seen before — reject replays |
Replay protection (jti). Every delivery attempt carries a unique jti.
Keep each accepted jti in a short-lived store (anything that outlives the
5-minute token lifetime works — a TTL cache, Redis with SET NX EX 600, a
small table) and reject a token whose jti you have already accepted. A
replayed request then fails verification even inside its exp window.
Reject anything that fails any check with a non-2xx status, and treat the JSON body as untrusted until the JWT verifies — the claims are the payload, so after verification you can safely act on the claims themselves.
Node.js
No dependencies — Node’s built-in crypto verifies RS256 directly:
import { verify as cryptoVerify } from 'node:crypto';
const seenJtis = new Map(); // jti -> exp; sweep expired entries periodically
export function verifyMvpWebhook(jwt, publicKeyPem, expectedAudience) {
const parts = jwt.split('.');
if (parts.length !== 3) return { valid: false, reason: 'malformed' };
const [headerB64, claimsB64, signatureB64] = parts;
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
if (header.alg !== 'RS256') return { valid: false, reason: 'unexpected alg' };
const signatureValid = cryptoVerify(
'RSA-SHA256',
Buffer.from(`${headerB64}.${claimsB64}`),
publicKeyPem,
Buffer.from(signatureB64, 'base64url'),
);
if (!signatureValid) return { valid: false, reason: 'bad signature' };
const claims = JSON.parse(Buffer.from(claimsB64, 'base64url').toString());
const now = Math.floor(Date.now() / 1000);
if (claims.iss !== 'mvp-payments.com') return { valid: false, reason: 'bad issuer' };
if (claims.aud !== expectedAudience) return { valid: false, reason: 'bad audience' };
if (typeof claims.exp !== 'number' || claims.exp <= now) {
return { valid: false, reason: 'expired' };
}
if (typeof claims.jti !== 'string' || seenJtis.has(claims.jti)) {
return { valid: false, reason: 'replayed jti' };
}
seenJtis.set(claims.jti, claims.exp);
return { valid: true, claims };
}
// In your handler:
// const result = verifyMvpWebhook(
// req.headers['x-mvp-signature'], PUBLIC_KEY_PEM, 'https://your.example/webhooks/mvp');
// if (!result.valid) return res.status(401).end();
// if (result.claims.sub !== req.headers['x-mvp-id']) return res.status(401).end();
// handlePayment(result.claims); // the verified claims ARE the payload
// res.status(200).end();
PHP
Using firebase/php-jwt
(composer require firebase/php-jwt):
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
const MVP_ISSUER = 'mvp-payments.com';
const WEBHOOK_URL = 'https://your.example/webhooks/mvp';
function verifyMvpWebhook(string $jwt, string $publicKeyPem): ?object
{
try {
// Verifies the RS256 signature and the exp claim.
$claims = JWT::decode($jwt, new Key($publicKeyPem, 'RS256'));
} catch (Exception $e) {
return null; // bad signature, malformed, or expired
}
if (($claims->iss ?? '') !== MVP_ISSUER) return null;
if (($claims->aud ?? '') !== WEBHOOK_URL) return null;
if (!is_string($claims->jti ?? null)) return null;
// Replay protection: first write wins; a seen jti rejects the request.
// APCu shown here — any store with a TTL ≥ 10 minutes works.
if (!apcu_add('mvp_jti_' . $claims->jti, 1, 600)) return null;
return $claims; // the verified claims ARE the payload
}
$jwt = $_SERVER['HTTP_X_MVP_SIGNATURE'] ?? '';
$claims = verifyMvpWebhook($jwt, $publicKeyPem);
if ($claims === null || $claims->sub !== ($_SERVER['HTTP_X_MVP_ID'] ?? '')) {
http_response_code(401);
exit;
}
handlePayment($claims);
http_response_code(200);
Python
Using PyJWT with the cryptography
extra (pip install "pyjwt[crypto]"):
import jwt # PyJWT
MVP_ISSUER = "mvp-payments.com"
WEBHOOK_URL = "https://your.example/webhooks/mvp"
seen_jtis = {} # jti -> exp; use Redis/memcached with a TTL in production
def verify_mvp_webhook(token: str, public_key_pem: str) -> dict | None:
try:
# Verifies the RS256 signature plus exp, aud and iss in one call.
claims = jwt.decode(
token,
public_key_pem,
algorithms=["RS256"],
audience=WEBHOOK_URL,
issuer=MVP_ISSUER,
options={"require": ["exp", "jti", "sub"]},
)
except jwt.InvalidTokenError:
return None
jti = claims["jti"]
if jti in seen_jtis:
return None # replay
seen_jtis[jti] = claims["exp"]
return claims # the verified claims ARE the payload
# In your handler (Flask shown):
# claims = verify_mvp_webhook(request.headers["x-mvp-signature"], PUBLIC_KEY_PEM)
# if claims is None or claims["sub"] != request.headers["x-mvp-id"]:
# return "", 401
# handle_payment(claims)
# return "", 200
Checklist
- RS256 signature verifies against the public key from your dashboard.
issismvp-payments.com;audis your exact webhook URL.expis in the future;jtihas never been accepted before.submatches the payloadmvpIdand thex-mvp-idheader.- Respond 2xx within 8 seconds; process idempotently (at-least-once delivery).
- After a key rotation, refresh the PEM from the dashboard.