This page covers how Mamo delivers webhooks, what happens on failure, and how to verify requests. To register an endpoint, see Register a Webhook.
Event catalog
| Event | Fires when | Payload reference |
|---|---|---|
payment.failed | A one-off payment attempt fails | Payment Object |
payment.succeeded | A one-off payment is captured | Payment Object |
payment.refund_initiated | A refund (full or partial) has started but not yet completed | Payment Object |
payment.refunded | A refund has completed | Payment Object |
payment.refund_failed | A refund attempt fails | Payment Object |
payment.card_verified | A card-verification payment succeeds (no charge captured) | Payment Object |
payment.authorized | A payment is placed on hold (hold_and_charge_later), not yet captured | Payment Object |
payment.voided | A held payment is reversed/voided before capture | Payment Object |
subscription.failed | A subscription payment attempt fails | Subscription Object |
subscription.succeeded | A subscription payment succeeds | Subscription Object |
payment_link.create | A payment link is created successfully | Payment Link Object |
payout.processed | A payout settles | Payout Object |
payout.failed | A payout is rejected | Payout Object |
expense.create | An expense is created | Expense Object |
expense.update | An expense is updated | Expense Object |
card_transaction.create | A card transaction is created | Transaction Object |
card_transaction.update | A card transaction is updated | Transaction Object |
dispute.received | A dispute/chargeback is opened against a payment | Payment Object |
dispute.evidence_submitted | Evidence has been submitted and the dispute is under review | Payment Object |
dispute.won | The dispute is resolved in your favor | Payment Object |
dispute.lost | The dispute is resolved against you | Payment Object |
dispute.expired | The response window closed without action | Payment Object |
dispute.closed | The dispute closed without a won/lost verdict (resolved or withdrawn) | Payment Object |
account.status_updated | Your account's published status changes (for example a deactivation) | — payload carries the new status, no dedicated object page yet |
The charge.* equivalents of the payment.* events above are kept as legacy aliases for backward compatibility — both names fire for the same underlying event, so an existing charge.* subscription keeps working unchanged. New integrations should subscribe to payment.*. Each webhook receives a single delivery per event: if you subscribe to both the payment.* and charge.* name for the same event, you only receive the payment.* one.
Registration requires a reachable endpoint
When you register a webhook, Mamo immediately sends a test request with the body {"ping": "pong"}. Your endpoint must respond with a 2xx status or registration is rejected with Webhook is unreachable (HTTP 422). Make sure your endpoint is live and publicly reachable before registering.
The test request is sent again when you call Create Webhook with the URL of an existing webhook. It is not signed (no Mamo-Signature header, see Verifying requests), so don't reject it for a missing signature.
Sandbox and live are separate environments with separate API keys — register your webhook in each environment separately.
Retries
If a delivery attempt fails (your endpoint does not return a 2xx), Mamo retries:
- Up to 5 attempts per event
- A fixed 30-minute wait between attempts (not exponential backoff)
After the 5th failed attempt the event is dropped and not retried further. Design your endpoint to return 2xx quickly and do heavy processing asynchronously, so a slow handler doesn't cause an unnecessary retry.
Verifying requests
Mamo-Signature header
Mamo-Signature headerEvery event delivery to a signed webhook includes a Mamo-Signature header: an HMAC-SHA256 of the raw request body, computed with your webhook's signing_secret and hex-encoded. Check it to confirm the request came from Mamo and the body wasn't changed in transit.
The signing_secret is returned once, in the response to Create Webhook when a new webhook is created. Store it securely: it is never returned again by list or update, or by a later POST /webhooks for the same URL (which only adds events to the existing webhook). If you lose it, delete the webhook and create it again to get a new secret.
Webhooks created before signing was introduced (2026-10-26) have no secret and their deliveries carry no Mamo-Signature header. To start receiving signed deliveries, delete the webhook and create it again.
To verify a delivery:
- Read the raw request body exactly as received. Don't parse and re-serialize it first: the signature covers the exact bytes sent.
- Compute
HMAC-SHA256(signing_secret, raw_body), hex-encoded. - Compare it to the
Mamo-Signatureheader with a constant-time comparison, never a plain==. - If they don't match, or the header is missing, reject the request.
The registration test request ({"ping":"pong"}) is the exception: it is sent before the webhook and its secret exist, so it has no Mamo-Signature header. Return a 2xx for it, or registration fails with Webhook is unreachable.
const crypto = require('crypto');
function isValidSignature(rawBody, signatureHeader, signingSecret) {
if (!signatureHeader) return false;
const expected = crypto.createHmac('sha256', signingSecret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
def is_valid_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
if not signature_header:
return False
expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)require 'openssl'
def valid_signature?(raw_body, signature_header, signing_secret)
return false if signature_header.nil? || signature_header.empty?
expected = OpenSSL::HMAC.hexdigest('sha256', signing_secret, raw_body)
expected.bytesize == signature_header.bytesize &&
OpenSSL.fixed_length_secure_compare(expected, signature_header)
endIf your framework parses JSON before your handler runs, capture the raw body first (for example express.raw({ type: 'application/json' }) in Express, or request.get_data() in Flask).
The signature is computed each time a delivery is sent, so a retry carries a signature for that attempt's body. Verify every attempt the same way.
auth_header
auth_headerThe auth_header value you set when registering the webhook is sent on every delivery in the Authorization header. You can check it as well as, or for older webhooks instead of, the signature. Treat a request whose Authorization header doesn't match your configured auth_header as untrusted.
Idempotency
The same event may be delivered more than once (for example, a 2xx that arrived after a timeout, or a retry). Make your handler idempotent — key on the event's id and ignore duplicates.