Webhook delivery
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
For every event Mamo sends, when it fires and its payload, see Webhook events.
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 returns a 4xx or 5xx, times out, or can't be reached), Mamo retries:
- Up to 5 attempts per event
- About 30 minutes between attempts (30 to 35 minutes; not exponential backoff)
- Each attempt waits up to 60 seconds for your response; a slower response counts as a failure
Redirects are not followed. A 3xx response counts as delivered and is not retried, so make sure the registered URL answers directly with 2xx.
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.
Each attempt, including the first, is built from the object's state when it is sent, not when the event happened. A retried payment.failed can therefore carry a newer status. Use event_type for what happened, and fetch the object if you need its current state.
Verifying requests
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:
- 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
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.
Payloads have no event ID. id is the ID of the payment (or payout, card transaction, expense or link), and every event about that object repeats it. Never deduplicate on id alone. Make the handler idempotent by applying state: update your record from the payload's status, refund_status, refund_amount, refunds[] and, on dispute.* events, dispute_status (see the Dispute object), and track refunds by each refunds[].id.
Deliveries can arrive out of order, and a failed delivery is retried later, so an older payload can arrive after a newer one. payment.refund_initiated payloads always report refund_status as initiated, even if the refund has since completed. Don't let an older or payment.refund_initiated payload move a record backwards: compare it against what you already stored, or call Fetch payment info to read the current state before updating.
Updated 5 days ago