Webhooks
Signed HTTPS callbacks to your server for every payment, order, payout and delivery. Set the endpoint, verify the signature, handle retries.
On this page
Setting an endpoint#
One endpoint per company covers both products. An owner sets it under MM2 Payments → Integration → Webhooks in the console. Saving hands back a signing secret (whsec_…) exactly once; we keep it encrypted and cannot show it again. Saving a new URL reissues the secret, and Rotate mints a new one for the same URL with no overlap window, because a company rotating is usually reacting to a leak.
| Rule | Why |
|---|---|
https:// only, on port 443 | The body carries your customers' activity. |
| No username or password in the URL | We would store it and you would log it. The signature is the authentication. |
| A public hostname | The host is resolved when you save and on every delivery, and anything resolving into a private range is refused. Repointing DNS later does not get around it. |
What a delivery looks like#
POST /mm2/webhooks HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
X-MM2-Event: payment.completed
X-MM2-Delivery: whd_3c1f9a2b8d4e7f60a1b2c3d4
X-MM2-Signature: t=1757077211,v1=5f1d…e8a3
{"id":"whd_3c1f9a2b8d4e7f60a1b2c3d4","type":"payment.completed","reference":"pay_6a93b48a38abc7ce614b60c4","partnerId":"6a8ae577a87a1007a21e15b6","data":{"payment":{…}}}idstring- The delivery id,
whd_plus 24 hex characters. Identical on every retry of the same event, so it is what you dedupe on. typestring- The event type. Same value as the
X-MM2-Eventheader. referencestring- The thing the event is about: a payment reference, an order reference, a withdrawal and its state.
partnerIdstring- Your company's id.
dataobject- The payload for the event type. See the event reference.
resendOfstring- Present only on a manual resend from the console, naming the original delivery id.
The body is compact JSON with no whitespace and no timestamp: it is byte-identical on every attempt, which is what lets you dedupe on id alone. The timestamp that matters is inside the signature.
Verifying the signature#
X-MM2-Signature is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 over the string "{t}.{raw body}" using your signing secret. The timestamp is inside the MAC, so a captured request cannot be replayed later with a fresh time.
- 1
Read the raw bytes
Compute over the exact body you received, before any JSON parsing or re-serialisation. A body your framework has re-encoded will not match.
- 2
Recompute
HMAC-SHA256 with your secret over
t, a literal dot, and the raw body. Hex-encode it. - 3
Compare in constant time
Use your language's constant-time comparison against
v1. - 4
Check the age
Reject a
tmore than 300 seconds from your clock.GET /partner/v1/pingreturns our server time if you want to check for drift.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/mm2/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-MM2-Signature') ?? ''
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
const expected = crypto
.createHmac('sha256', process.env.MM2_WEBHOOK_SECRET)
.update(`${parts.t}.`)
.update(req.body) // the raw Buffer, untouched
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300
const valid =
expected.length === (parts.v1 ?? '').length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
if (!fresh || !valid) return res.status(400).end()
const event = JSON.parse(req.body)
if (alreadyProcessed(event.id)) return res.status(200).end() // dedupe on the delivery id
handle(event) // your logic
res.status(200).end()
})import hmac, hashlib, time, json
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/mm2/webhooks")
async def mm2_webhook(request: Request):
raw = await request.body()
parts = dict(kv.split("=", 1) for kv in request.headers["X-MM2-Signature"].split(","))
expected = hmac.new(
SECRET.encode(), f"{parts['t']}.".encode() + raw, hashlib.sha256
).hexdigest()
if abs(time.time() - int(parts["t"])) > 300:
return Response(status_code=400)
if not hmac.compare_digest(expected, parts.get("v1", "")):
return Response(status_code=400)
event = json.loads(raw)
if not already_processed(event["id"]):
handle(event)
return Response(status_code=200)Retries and the delivery log#
Any response other than a 2xx, and any timeout, is a failed attempt. We retry with exponential backoff, roughly 2, 4, 8, 16, 32 minutes, then hourly, up to 20 attempts. Respond quickly with a 2xx and do the work afterwards; a receiver that takes seconds to answer is a receiver that times out.
Every attempt is written to the delivery log under MM2 Payments → Integration, kept for 30 days: the status code we got (or the error when there was no response), the exact signed body, and a state:
| State | Meaning |
|---|---|
pending | Queued, not yet attempted. |
retrying | Attempted and refused, with retries still to come. |
succeeded | Your endpoint answered 2xx. |
failed | Every attempt was refused. Lost until someone presses Resend. |
skipped | There was no endpoint configured when the event came up. The answer to "where did my webhooks go" that is invisible everywhere else. |
- Resend replays a delivery as a new delivery with a new id and
resendOfnaming the original, because your handler dedupes on the id and replaying the same one is exactly what it ignores. - Send test event posts a
webhook.testwith obviously fake figures (a one-cent payment) so you can fail before a customer is involved. - Failed deliveries also raise the Integration health card on your dashboard and, if you have connected one, a Discord notification.
Handling events well#
- Dedupe on `id`. Retries carry the same id; a resend carries a new one plus
resendOf. Store processed ids. - Do not assume order. A
withdrawal.updatedcan arrive before thepayout.order_completedthat created it if your endpoint was briefly down. Key your state on the ids in the payload, not on arrival order. - Credit once, from the rollup. For MM2 Payments,
order.completedis the event to credit a user from;payment.completedis for showing progress. - Trust the webhook, not the pop-up.
onCompletein the browser is a convenience for your UI. The signed event is the record. - Read back when in doubt. Every event has an API read behind it:
GET /payments/{id},GET /orders/{orderId},GET /payouts/orders/{id},GET /payments/withdrawals/{id}.