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.

RuleWhy
https:// only, on port 443The body carries your customers' activity.
No username or password in the URLWe would store it and you would log it. The signature is the authentication.
A public hostnameThe 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#

http
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":{…}}}
Body
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-Event header.
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. 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. 2

    Recompute

    HMAC-SHA256 with your secret over t, a literal dot, and the raw body. Hex-encode it.

  3. 3

    Compare in constant time

    Use your language's constant-time comparison against v1.

  4. 4

    Check the age

    Reject a t more than 300 seconds from your clock. GET /partner/v1/ping returns our server time if you want to check for drift.

Node (Express)
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()
})
Python (FastAPI)
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:

StateMeaning
pendingQueued, not yet attempted.
retryingAttempted and refused, with retries still to come.
succeededYour endpoint answered 2xx.
failedEvery attempt was refused. Lost until someone presses Resend.
skippedThere 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 resendOf naming 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.test with 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.updated can arrive before the payout.order_completed that 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.completed is the event to credit a user from; payment.completed is for showing progress.
  • Trust the webhook, not the pop-up. onComplete in 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}.