mechawallet_
Sell

Webhooks#

Get told when you've been paid, instead of polling.

Register an endpoint#

curl -X POST https://api.mechawallet.com/v1/webhooks \
  -H "Authorization: Bearer $MW_KEY" \
  -H "content-type: application/json" \
  -d '{"url": "https://yourco.com/hooks/mechawallet"}'
{
  "id": "wh_fov8_997ae7UpA",
  "url": "https://yourco.com/hooks/mechawallet",
  "events": ["checkout.paid"],
  "secret": "whsec_...",
  "note": "store the secret now — verify deliveries with it"
}

Lost the secret? Run the same call again

The secret is shown once per (re)registration, like an API key — it can never be read back. But registration is idempotent by URL: POSTing the same url again re-keys the existing endpoint and returns a fresh secret ("rekeyed": true in the response), reactivated and with its failure counter cleared. The old secret stops verifying from the next delivery on — which also makes the same call how you revoke a leaked secret. Safe to run on every deploy: your receiving service still stores the secret in its own config and verifies each delivery with it; only obtaining it is ever-repeatable. DELETE /v1/webhooks/{id} removes a receiver outright.

Every delivery also carries X-Mechawallet-Ed25519: t=<unix>,v1=<base64 sig> — an Ed25519 signature over the same "{t}.{raw_body}", verifiable with the public key at GET /v1/webhooks/verification-key (no auth). Fetch the key at boot, verify each delivery, reject a stale t. Your service then needs no webhook secret in its configuration at all — the API key you already hold is the only credential a deploy requires:

import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

PUB = Ed25519PublicKey.from_public_bytes(base64.b64decode(
    httpx.get("https://api.mechawallet.com/v1/webhooks/verification-key")
    .json()["public_key"]))

def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    t, sig = header.split(",", 1)
    t, sig = t.split("=")[1], base64.b64decode(sig.split("=", 1)[1])
    if abs(time.time() - int(t)) > tolerance:
        return False
    try:
        PUB.verify(sig, f"{t}.".encode() + raw_body)
        return True
    except Exception:
        return False

The key can rotate with the service's own secret rotation: refetch it on a verification failure before treating a delivery as forged. Replays of your own events are handled the way you already must handle redeliveries — idempotency on the payment id.

Verify with the per-endpoint secret (alternative)#

Prefer a symmetric secret you hold yourself? Each request also carries a signature header keyed with your endpoint's secret:

X-Mechawallet-Signature: t=1785226440,v1=<hmac-sha256>

The signed value is "{t}.{raw_body}", keyed with your webhook secret.

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, sig = parts["t"], parts["v1"]

    # Reject stale timestamps BEFORE comparing, or a captured delivery can be
    # replayed against you forever.
    if abs(time.time() - int(t)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, sig)   # constant-time, not ==

Two ways to get this wrong

Verify against the raw bytes, not a re-serialized JSON object — re-encoding changes whitespace and key order, and the signature won't match. And compare with compare_digest; a plain == leaks the answer through timing.

The checkout.paid payload#

Zero-derivation is the rule: everything a receiver acts on arrives in the event, so nothing needs a follow-up lookup.

{
  "event": "checkout.paid",
  "created_at": "2026-08-25T14:03:11+00:00",
  "data": {
    "checkout_id": "chk_abc123",
    "title": "Sillón de dos cuerpos",
    "payment_id": "pay_dVo7SDQsatf4HIFC",
    "network": "eip155:8453",
    "asset": "USDC",
    "amount_usd": "100.00",
    "provider_amount": 95000000,
    "platform_amount": 5000000,
    "platform_fee_to": "0xPlatform…",
    "fee_amount": 510000,
    "total": 100510000,
    "payer": "0xBuyer…",
    "pay_to": "0xSeller…",
    "tx_hash": "0x…",
    "receipt_url": "https://api.mechawallet.com/r/pay_dVo7SDQsatf4HIFC",
    "sold_by": "martina",
    "parties": {"seller": {"name": "Martina", "ref": "usr_…"}},
    "handoff_id": null,
    "test_mode": false,
    "settled_at": "2026-08-25T14:03:10+00:00"
  }
}

The amounts are atomic units of the settling asset; amount_usd is the seller's price as a string. For platforms, the three that matter arrive together: provider_amount (the sub-seller's take), platform_amount (your cut — null when no platform fee), sold_by and parties (your own references, verbatim, so routing needs no lookup in either direction). receipt_url is ready to relay to the buyer. handoff_id is covered below. test_mode deliveries are rehearsals — anyone can pay a /test face; a production receiver must drop them or a stranger can forge a sale.

Escrow events#

Escrowed payments fire checkout.paid only when the escrow releases — never at deposit. Money in escrow can still refund, so anything you do on checkout.paid (credit a sale, send a "you got paid" email, write a ledger row) stays exactly as safe as it was. If you change nothing, escrow simply looks like a slower checkout.paid.

Subscribe to the earlier lifecycle if your UI wants it:

Event Means Credit a sale?
escrow.held deposited, money in the vault no — can still refund
escrow.on_hold buyer paused the auto-release no
escrow.escalated buyer asked the arbiter to rule no — and if the arbiter is you, this is your cue
escrow.released money went to the seller yes (checkout.paid fires too)
escrow.refunded money went back to the buyer never

Buyer attribution: handoff_id#

checkout.paid and every escrow event carry handoff_id — the nonce the settlement arrived with, or null. If your platform minted that nonce for a signed-in buyer and put it on the pay URL as ?h=<nonce>, this field is how the purchase finds its account: match it server-side and attach the order the moment the event lands, no claim step, no matter what device or agent paid. The full pattern (minting, scoping, expiry, the poll endpoint for live browser pairing) is in the platform guide.

Delivery behaviour#

Retries Automatic, with backoff, via a sweep
Ordering Not guaranteed — use the payment id
Duplicates Possible. Make your handler idempotent on payment.id
Success Any 2xx. Anything else is retried

Respond fast and do the work afterwards. A slow handler looks like a failure and earns you a retry.

Single-writer sweep

The retry sweep assumes one instance. If you self-host, run one web instance — see the deploy notes in GO_LIVE.md.

Don't trust the payload alone#

The webhook body tells you a payment happened. The receipt is the authority, and the chain is the authority behind that:

r = httpx.get(f"https://api.mechawallet.com/r/{payload['payment']['id']}")
receipt = r.json()
# receipt["tx_hash"] is on-chain and verifiable without trusting anyone

For anything that releases real value, check the receipt — or the transaction itself — before acting.

Alternative: just poll#

For low volume, polling is honestly fine and has fewer moving parts:

mw checkouts list --json | jq '.checkouts[] | select(.status == "paid")'