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.
Verify with no stored secret (recommended)#
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")'