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. To also receive payment.status.changed on
every public status transition, switch it on yourself on the Webhooks
page of your dashboard: tick
Also send a webhook on every status change (not just the final one) and
save.
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
Each environment has its own endpoint, set on the Webhooks page of your dashboard: sandbox payments post to your Sandbox endpoint, live payments to your Live endpoint. The two can be the same URL or different ones; leave one empty and that environment sends no webhooks.
Every delivery is an HTTPS POST to the endpoint of the payment’s
environment:
| 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 |
x-mvp-test | true — on test events only, never on a real delivery |
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, status) — with
payment.status.changed enabled, one payment sends an event for each status
it reaches.
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. The same
key signs your Sandbox and Live deliveries.
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 the URL of the endpoint it was delivered to — your Sandbox or Live endpoint, character for character as saved |
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.
Check the environment. The claims include the payload’s isSandbox. A
real delivery to your Live endpoint always carries false, and one to your
Sandbox endpoint true, so check that it matches the environment the
endpoint serves — if both environments share one URL, it is how you tell
them apart. If one handler serves both endpoints, compare aud with the
saved URL of the endpoint the request came in on, taken from your own
configuration and never from the request’s headers; the samples below take
it as expectedAudience (Node.js) or WEBHOOK_URL (PHP, Python).
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.
Test events and examples
The Webhooks page of your dashboard sends test events — an initiated payment and a failed one — to your Sandbox or Live endpoint, and shows exactly what was sent: the URL, every header, the body, and the decoded claims of the signature. Build and check your verification against them.
A test event is signed and delivered like a real one, with aud set to the
endpoint it was sent to, and it is marked in ways a real delivery never is:
test: truein the body and in the signed claims;- the header
x-mvp-test: true; - a
TEST-prefixedmvpId, andisSandbox: true— even at your Live endpoint.
Verify a test event like any other and acknowledge it with a 2xx, but never
fulfil anything from one. Rely on the signed test claim: the header only
lets you spot a test before you verify it.
The same page also shows signed examples of both events — headers, body
and decoded claims — without sending anything. Examples are signed with your
real key for the placeholder audience https://example.invalid/webhooks/mvp,
so within its five-minute lifetime an example passes every check except
aud. A real delivery’s aud is always your endpoint’s URL.
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 the exact URL of the endpoint that received it.expis in the future;jtihas never been accepted before.submatches the payloadmvpIdand thex-mvp-idheader.isSandboxmatches the environment the endpoint serves — except on a test event (next).- A test event (
test: true,x-mvp-test: true, aTEST-prefixedmvpId, alwaysisSandbox: true) is acknowledged, never fulfilled. - Respond 2xx within 8 seconds; process idempotently on (
mvpId,x-mvp-event,status) — delivery is at least once. - After a key rotation, refresh the PEM from the dashboard.