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. 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:

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

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:

ClaimCheck
(signature)verifies as RS256 against your published public key
issexactly mvp-payments.com
audexactly your webhook URL
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.

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

  1. RS256 signature verifies against the public key from your dashboard.
  2. iss is mvp-payments.com; aud is your exact webhook URL.
  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. Respond 2xx within 8 seconds; process idempotently (at-least-once delivery).
  6. After a key rotation, refresh the PEM from the dashboard.