Skip to main content

Getting started with webhooks

This guide covers the minimum setup to start receiving Max Pay events in your system: create a webhook, receive the secret, verify the HMAC signature, send a test delivery and handle deduplication.

To understand the model and semantics, see Webhooks (concepts). For the full operational reference (secret rotation, pause/resume, diagnostics), see Webhooks configuration.

1. Create the webhook

Configure a public HTTPS endpoint in your system. Then register it:

POST /v1/webhooks
Idempotency-Key: setup-erp-prod-001
Content-Type: application/json

{
"url": "https://erp.distribuidora-demo.com/maxpay/events",
"events": [
"receivable-account.activated",
"receivable.paid",
"receivable.settled",
"movement.created",
"settlement.completed"
]
}

The response includes the secret once:

201 Created

{
"id": 301,
"url": "https://erp.distribuidora-demo.com/maxpay/events",
"events": [...],
"status": "ACTIVE",
"secret": "whsec_aB3xY9mNpQ2rTzV5wK7jH8nM4fdK",
...
}
Store the secret now

The secret is only returned in the creation or rotation response. After that, you will only see secretMaskedTail. If you lose it, you need to rotate (see Configuration → Rotate secret).

2. Verify the HMAC signature

Every delivery arrives with X-MaxPay-Signature: t=<timestamp>,v1=<hmac>. Your endpoint must recompute the HMAC and reject the delivery if it does not match.

const crypto = require('crypto');

function verifyMaxpayWebhook(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(s => s.split('='))
);
const timestamp = parts.t;
const signature = parts.v1;

// 1. Reject old timestamps (replay attack)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) return false;

// 2. Recompute HMAC over `<timestamp>.<body>`
const payload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');

// 3. Constant-time compare
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
}
Use the raw body, not the parsed JSON

The HMAC is computed over the exact HTTP body Max Pay sent. If your framework parses the JSON before handing it to you, you lose bytes (field order, whitespace). Configure your middleware to preserve the rawBody.

3. Send a test delivery

To validate your endpoint works before exposing it to real traffic:

POST /v1/webhooks/301/deliveries
Content-Type: application/json

{
"eventType": "webhook.test"
}

Max Pay sends a webhook.test event to your URL and returns the result. If your endpoint responds 2xx and the signature verifies, the setup is ready.

4. Handle deduplication

Because of the "at-least-once" model, you may receive the same event more than once. The event.id is unique and immutable across retries of the same event.

async function handleEvent(req, res) {
if (!verifyMaxpayWebhook(secret, req.headers['x-maxpay-signature'], req.rawBody)) {
return res.status(401).end();
}

const event = req.body;

// Idempotency: if we already processed this event.id, return 200 without reprocessing
const seen = await db.events.findOne({ eventId: event.id });
if (seen) return res.status(200).end();

// Respond fast, process in background
await db.events.insert({ eventId: event.id, type: event.type, receivedAt: new Date() });
res.status(200).end();

// Async processing
await processEvent(event);
}

Production-readiness checklist

  • Public HTTPS endpoint with a valid certificate.
  • Raw body available for signature verification.
  • HMAC verification implemented and old timestamps rejected.
  • Deduplication by event.id.
  • Fast 2xx response (async processing if the logic is heavy).
  • Logging of X-MaxPay-Delivery-Id for support correlation.
  • Plan for handling rollbacks (movement.created with operationType: ROLLBACK).
  • Subscription to the receivable-account.cvu-failed event if you issue receivable accounts.

Next steps