Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.minisend.xyz/llms.txt

Use this file to discover all available pages before exploring further.

Each request includes X-Minisend-Signature — an HMAC-SHA256 hex of the raw JSON body, keyed with your webhook_secret. Verify it before processing.
Find your webhook_secret in Settings. Treat it like a password.
Use a timing-safe comparison. === is vulnerable to timing attacks.

Sign over the raw body

The signature covers the exact bytes Minisend sent. Re-stringifying the parsed body (JSON.stringify(req.body)) can produce different bytes and break verification. Capture the raw body before parsing. In Express, use express.raw({ type: 'application/json' }) for the webhook route.

Verify

const crypto = require('crypto');

function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');

  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Full Express handler

const express = require('express');
const crypto = require('crypto');
const app = express();

// Raw body required for signature verification
app.use(
  '/webhooks/minisend',
  express.raw({ type: 'application/json' }),
);

function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/minisend', (req, res) => {
  const signature = req.headers['x-minisend-signature'];
  if (!signature) return res.status(401).json({ error: 'Missing signature' });

  if (!verifyWebhook(req.body, signature, process.env.MINISEND_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body.toString('utf8'));

  // Acknowledge immediately, then process
  res.status(200).send('OK');

  switch (payload.event) {
    case 'checkout.completed':
      markOrderPaid(payload.external_id, {
        session_id: payload.session_id,
        receipt: payload.receipt,
        amount_local: payload.amount_local,
        currency: payload.currency,
      });
      break;
    case 'checkout.failed':
      handleFailedPayment(payload.external_id, payload.session_id);
      break;
    case 'checkout.expired':
      handleExpiredSession(payload.external_id, payload.session_id);
      break;
  }
});

app.listen(3000);
Return 2xx before running business logic. Anything taking >10s triggers a retry.