Skip to content

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:

HeaderValue
content-typeapplication/json
x-mvp-signaturean RS256 JWT signed with your dedicated key (see below)
x-mvp-idthe payment’s mvpId
x-mvp-attemptdelivery attempt, 1–5
x-mvp-eventpayment.finality or payment.status.changed
x-mvp-testtrue — 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:

ClaimCheck
(signature)verifies as RS256 against your published public key
issexactly mvp-payments.com
audexactly the URL of the endpoint it was delivered to — your Sandbox or Live endpoint, character for character as saved
subequals the payload’s mvpId and the x-mvp-id header
expin the future (tokens live 5 minutes from signing)
jtinever 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: true in the body and in the signed claims;
  • the header x-mvp-test: true;
  • a TEST-prefixed mvpId, and isSandbox: 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

  1. RS256 signature verifies against the public key from your dashboard.
  2. iss is mvp-payments.com; aud is the exact URL of the endpoint that received it.
  3. exp is in the future; jti has never been accepted before.
  4. sub matches the payload mvpId and the x-mvp-id header.
  5. isSandbox matches the environment the endpoint serves — except on a test event (next).
  6. A test event (test: true, x-mvp-test: true, a TEST-prefixed mvpId, always isSandbox: true) is acknowledged, never fulfilled.
  7. Respond 2xx within 8 seconds; process idempotently on (mvpId, x-mvp-event, status) — delivery is at least once.
  8. After a key rotation, refresh the PEM from the dashboard.