Webhook delivery & retries

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

EventFires whenPayload reference
payment.failedA one-off payment attempt failsPayment Object
payment.succeededA one-off payment is capturedPayment Object
payment.refund_initiatedA refund (full or partial) has started but not yet completedPayment Object
payment.refundedA refund has completedPayment Object
payment.refund_failedA refund attempt failsPayment Object
payment.card_verifiedA card-verification payment succeeds (no charge captured)Payment Object
payment.authorizedA payment is placed on hold (hold_and_charge_later), not yet capturedPayment Object
payment.voidedA held payment is reversed/voided before capturePayment Object
subscription.failedA subscription payment attempt failsSubscription Object
subscription.succeededA subscription payment succeedsSubscription Object
payment_link.createA payment link is created successfullyPayment Link Object
payout.processedA payout settlesPayout Object
payout.failedA payout is rejectedPayout Object
expense.createAn expense is createdExpense Object
expense.updateAn expense is updatedExpense Object
card_transaction.createA card transaction is createdTransaction Object
card_transaction.updateA card transaction is updatedTransaction Object
dispute.receivedA dispute/chargeback is opened against a paymentPayment Object
dispute.evidence_submittedEvidence has been submitted and the dispute is under reviewPayment Object
dispute.wonThe dispute is resolved in your favorPayment Object
dispute.lostThe dispute is resolved against youPayment Object
dispute.expiredThe response window closed without actionPayment Object
dispute.closedThe dispute closed without a won/lost verdict (resolved or withdrawn)Payment Object
account.status_updatedYour 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

Every 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:

  1. Read the raw request body exactly as received. Don't parse and re-serialize it first: the signature covers the exact bytes sent.
  2. Compute HMAC-SHA256(signing_secret, raw_body), hex-encoded.
  3. Compare it to the Mamo-Signature header with a constant-time comparison, never a plain ==.
  4. 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)
end

If 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

The 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.