Morzepay API
Collect mobile money, send payouts and give each of your own customers a wallet with its own balance. Every request is made from your server with an API key.
Base URL:
Requests and responses are JSON. Successful responses look like {"success": true, "data": {...}}. Supported networks today: MTN and Airtel Uganda, in UGX.
How wallets fit together
Your business has one or more main wallets. Under each main wallet you can create sub-wallets, one per customer on your platform (a shop on your POS, a member of your app). Each wallet has its own balance and transaction history. Money moves into a wallet through collections or transfers, and out through payouts or transfers.
Authentication
Create API keys in the dashboard under Developers. Send the key in the Authorization header. Keys are shown once; store them in your server's environment, never in a browser or mobile app.
Authorization: Bearer mp_live_3f9a1c0b2e7d_9b1c...
Keys created before the rename start with tp_ instead of mp_; they keep working, so there is nothing to change.
Every key belongs to one business and is limited in three ways:
| Control | What it does |
|---|---|
| Permissions | wallets:read, wallets:write, deposits:write, payouts:write, transfers:write, transactions:read, msisdn:validate. A call without the needed permission returns 403 INSUFFICIENT_SCOPE. |
| Wallets | Optionally limited to chosen wallets. A main wallet in the list includes all its sub-wallets. Other wallets return 404 WALLET_NOT_FOUND, as if they did not exist. |
| Server IPs | Optional allowlist of IPs or CIDR ranges. Required for live keys with payouts:write. |
Sandbox and live
Every account has two separate environments. Test keys (mp_test_…) only see test wallets and never touch real money. Live keys (mp_live_…) only see live wallets and are available after your business is verified. Wallet, transaction and reference namespaces are fully separate, so the same reference can exist in both.
Every object includes livemode (true or false), including webhook payloads.
Test phone numbers
| Number | What happens |
|---|---|
+256700000002 | The customer declines (collections) or the payout fails. Funds held for a payout are returned. |
+256700000003 | Stays processing for about 60 seconds, then succeeds. Use it to test polling and webhooks. |
+256700000004 | Not a registered mobile money number: rejected immediately, and fails verification. |
| Any other valid number | Succeeds immediately with the same fees as live (3.5% on collections, the band fee on payouts, UGX 200 on transfers). |
The 5-requests-per-number-per-10-minutes rule applies in the sandbox too, so you can test your handling of MSISDN_RATE_LIMITED. Test traffic never counts against live limits.
Going live
Verify your business under Go live in the dashboard. Live payouts are limited per rolling 7 days according to your account type; the remaining allowance is returned by Quote fees as weekly_limit_remaining.
Amounts and fees
Send amounts as whole UGX integers: 5000, not "5000" or 5000.5. Mobile money transactions must be between 500 and 5,000,000 UGX. Responses return money as decimal strings, such as "1136.88", because network fees can include fractions.
Collection fees
Collections cost 3.5% of the amount the customer pays, all-in. Each wallet decides who pays it with deposit_fee_bearer, and you can override it per request with fee_bearer:
| Value | Customer is asked for | Wallet receives |
|---|---|---|
wallet | The amount you send, e.g. 10,000 | Amount minus 3.5%, e.g. 9,650 |
customer | Amount plus the fee, e.g. 10,363 | About the amount, e.g. 10,000 |
Payout fees
Transfer fees
Each wallet-to-wallet transfer costs a flat UGX 200, paid by the sending wallet on top of the amount. Any fee you add yourself (see Transfers) is extra and goes to your own fee wallet.
Payout fees
Payout fees are a flat amount per band, starting at UGX 800. Before sending, Morzepay holds the payout amount plus an estimated fee with a safety buffer. If the wallet can't cover that, the payout is refused with 402 INSUFFICIENT_FUNDS and nothing is sent. When the payout completes, any unused part of the estimate goes back to the wallet. Use Quote fees to show the numbers before you send.
Errors
{
"success": false,
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "The wallet cannot cover the payout plus the estimated mobile money fee.",
"details": { "available": "3000.00", "required": "5800.00", "amount": "5000.00", "estimated_fee": "800.00" }
},
"request_id": "req_6f1d0c2b9a8e7d6c5b4a"
}
| HTTP | Code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED, KEY_REVOKED | Missing, wrong, revoked or expired key. |
| 402 | INSUFFICIENT_FUNDS | The wallet balance can't cover the amount and fees. |
| 403 | INSUFFICIENT_SCOPE, IP_NOT_ALLOWED, LIVE_NOT_ENABLED | The key or business isn't allowed to do this. |
| 403 | WEEKLY_LIMIT_REACHED | The payout would exceed your 7-day live payout limit. details.remaining shows what's left. |
| 404 | WALLET_NOT_FOUND, NOT_FOUND | Doesn't exist, or this key can't see it. |
| 409 | DUPLICATE_REFERENCE, WALLET_NOT_ACTIVE | Reference already used (the existing transaction_id is in details), or wallet is frozen. |
| 422 | VALIDATION_ERROR, MODE_MISMATCH | A field is wrong (details.field names it), or you mixed test and live wallets. |
| 429 | RATE_LIMITED, MSISDN_RATE_LIMITED | Too many requests. Wait details.retry_after_seconds. |
| 5xx | SERVER_ERROR | Our side. Safe to retry with the same Idempotency-Key. |
Idempotency
Network problems happen. To retry a POST safely, send an Idempotency-Key header (8–80 letters, digits, - or _). Retrying with the same key and body returns the original response with an Idempotent-Replayed: true header, and never moves money twice. Reusing a key with a different body returns 422 IDEMPOTENCY_MISMATCH. Keys are kept for 24 hours.
Money-moving calls also require your own unique reference (8–36 characters). A second request with the same reference returns 409 DUPLICATE_REFERENCE.
Rate limits
Each key can make 120 requests per minute. Separately, the mobile networks allow at most 5 payment requests per phone number every 10 minutes. A sixth request returns 429 MSISDN_RATE_LIMITED with retry_after_seconds, so show your customer when they can try again rather than retrying in a loop.
List wallets
Permission wallets:read. Optional query parameters: external_ref (find the wallet for one of your customers), parent_wallet_id, limit (1–100, default 50), starting_after (a wallet id, for paging).
curl "https://morzepay.com/v1/wallets?external_ref=shop-0192" \
-H "Authorization: Bearer $MORZEPAY_KEY"
Create a sub-wallet
Permission wallets:write. Main wallets are created in the dashboard; the API creates sub-wallets under them.
| Field | Description |
|---|---|
parent_wallet_id required | The main wallet this sits under. |
name required | 2–120 characters. |
external_ref | Your own ID for this customer. Unique per business, so you can look the wallet up later. |
deposit_fee_bearer | wallet or customer. Defaults to the parent's setting. |
curl "https://morzepay.com/v1/wallets" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-shop-0192" \
-d '{
"parent_wallet_id": "wal_8fK2mQx7RtP4nVb9LcHs2w",
"name": "Nakasero Pharmacy",
"external_ref": "shop-0192",
"deposit_fee_bearer": "customer"
}'
Response 201
{
"success": true,
"data": {
"id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"kind": "sub",
"parent_wallet_id": "wal_8fK2mQx7RtP4nVb9LcHs2w",
"name": "Nakasero Pharmacy",
"external_ref": "shop-0192",
"currency": "UGX",
"balance": "0.00",
"deposit_fee_bearer": "customer",
"status": "active",
"created_at": "2026-10-04T14:20:11+03:00"
}
}
Wallet balance
Permission wallets:read. The wallet id is required: a key never sees a combined balance it isn't allowed to.
curl "https://morzepay.com/v1/wallets/wal_Zq4Hn8YtV2kPm6RbX3cLw9" \
-H "Authorization: Bearer $MORZEPAY_KEY"
Response 200
{ "success": true, "data": { "id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9", "balance": "48500.00", "currency": "UGX", "status": "active", ... } }
Wallet transactions
Permission transactions:read. Newest first. Query: limit (1–100, default 25) and starting_after (a transaction id) to page backwards. The response has items and has_more.
Quote fees
Permission wallets:read. Shows what a collection, payout or transfer will cost before you make it. Nothing is created. type is deposit, payout or transfer; for transfers you can include your own fee to see the total.
curl "https://morzepay.com/v1/quotes" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" -H "Content-Type: application/json" \
-d '{ "type": "payout", "wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9", "amount": 20000, "msisdn": "+256771234567" }'
{ "success": true, "data": {
"type": "payout", "amount": "20000.00", "estimated_fees": "1100.00",
"amount_held": "21100.00", "wallet_balance": "48500.00", "sufficient_funds": true } }
Collect a payment
Permission deposits:write. Sends a payment prompt to the customer's phone. The wallet is credited only after the customer approves and the network confirms.
| Field | Description |
|---|---|
wallet_id required | Wallet to credit. |
msisdn required | Payer's number. +256771234567, 256771234567 and 0771234567 are accepted. |
amount required | Whole UGX, 500 – 5,000,000. |
reference required | Your unique reference, 8–36 characters. |
fee_bearer | wallet or customer. Defaults to the wallet's setting. |
description | Up to 100 characters. |
metadata | Up to 20 string keys with simple values. Returned on the transaction and in webhooks. |
curl "https://morzepay.com/v1/deposits" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-77812" \
-d '{
"wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"msisdn": "+256771234567",
"amount": 10000,
"reference": "order-77812",
"fee_bearer": "customer",
"description": "Order 77812",
"metadata": { "order_id": "77812" }
}'
Response 201
{
"success": true,
"data": {
"id": "txn_Rk3Lp9WmQ2vX7bN4cTz8Hd",
"type": "deposit",
"status": "processing",
"wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"amount": "10000.00",
"customer_charged": "10363.00",
"fee_bearer": "customer",
"net": null,
"msisdn": "+256771234567",
"provider": "MTN_UGANDA",
"fee": null,
"fee_estimated": "363.00",
"reference": "order-77812",
...
}
}
Status lifecycle
processing means the prompt is on the customer's phone. Wait for the deposit.succeeded or deposit.failed webhook, or poll Get a transaction. A rare review status means the network reported something unexpected (for example, a different amount) and our team is checking it; no money is credited until it's resolved.
Send a payout
Permission payouts:write and an IP allowlist on the key. Same fields as a collection, except fee_bearer. The wallet pays the network fee.
curl "https://morzepay.com/v1/payouts" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: withdraw-5521" \
-d '{
"wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"msisdn": "+256701234567",
"amount": 20000,
"reference": "withdraw-5521",
"description": "Shop withdrawal"
}'
The response includes amount_held, the amount plus the fee estimate taken from the wallet immediately. On payout.succeeded, fees.provider holds the real fee and net the final change to the wallet. On payout.failed, the full hold returns to the wallet.
Payouts are never marked failed just because the network is slow. If the outcome is unclear, the payout stays processing until the network confirms it, and moves to review after 24 hours.
Transfer between wallets
Permission transfers:write. Instant, with a flat UGX 200 transfer fee paid by the sender. Both wallets must belong to your business and be visible to the key.
| Field | Description |
|---|---|
from_wallet_id required | Wallet to debit. |
to_wallet_id required | Wallet to credit. |
amount required | Whole UGX, 1 – 50,000,000. |
reference required | Your unique reference. |
fee | Optional fee you charge the sender, in UGX. Debited from the sender on top of amount. |
fee_wallet_id | Where the fee goes. Defaults to the sender's main wallet. |
description, metadata | As above. |
Example: a peer-to-peer app charging 1% (UGX 500 on 50,000), collected into its main wallet:
curl "https://morzepay.com/v1/transfers" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: p2p-90311" \
-d '{
"from_wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"to_wallet_id": "wal_Pt7Kc2NxW9mQ4vR8bLd3Hy",
"amount": 50000,
"fee": 500,
"reference": "p2p-90311",
"description": "Send 50k to John"
}'
The response is the sender's transfer_out transaction with status: "success". The receiver gets a matching transfer_in, and the fee wallet a fee_in.
Verify a phone number
Permission msisdn:validate. Returns the registered name so your customer can confirm who they're paying. MTN and Airtel Uganda only. Limited to 30 checks per minute per key.
curl "https://morzepay.com/v1/msisdn/validate" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" -H "Content-Type: application/json" \
-d '{ "msisdn": "0771234567" }'
{ "success": true, "data": { "msisdn": "+256771234567", "valid": true, "customer_name": "JOHN DOE", "provider": "MTN_UGANDA", "message": null } }
Get a transaction
Permission transactions:read. Use it to poll if you can't receive webhooks; every 10–30 seconds is plenty.
Fee fields: fee_estimated is known when the transaction is created; fee is the final fee once it succeeds (for collections it's always the advertised 3.5%). For transfers, fee is the UGX 200 transfer fee and merchant_fee is any fee you added.
| Status | Meaning |
|---|---|
pending | Created, not yet accepted by the network. |
processing | With the network, waiting for the customer or the network to finish. |
success | Done. The wallet balance has changed by net. |
failed | Didn't happen. Any held funds are back in the wallet. See failure_reason. |
review | Being checked by Morzepay. Final status follows. |
Create an invoice
Permission invoices:write. Invoices work like Stripe's: you create a draft, finalize it (which gives it a number and freezes it), and your customer pays it on a hosted page with MTN Mobile Money or Airtel Money. We email them a PDF copy and a receipt when they pay.
| Field | Description |
|---|---|
customer required | { "name", "email", "phone", "address" }. Only name is required. email is needed to email the invoice; phone to text it and to prefill the payment page. |
items required | Up to 50 lines: { "description", "quantity", "unit_amount" }. Amounts in whole UGX. |
tax_percent | Applied to the subtotal, e.g. 18 for VAT. Rounded to the nearest shilling. |
due_date or days_until_due | 2026-11-30, or a number of days. Defaults to your invoice settings (14 days). |
wallet_id | Where payments land. Defaults to your main wallet. Use a sub-wallet to keep each branch or client separate. |
fee_bearer | wallet or customer. With customer, the 3.5% collection fee is added on the payment page and the wallet receives the full invoice total. |
memo, footer | Text shown on the invoice (up to 1000 and 500 characters). |
reference | Your own unique id for the invoice, up to 64 characters. |
metadata | Up to 20 keys. Returned on the invoice and in webhooks. |
finalize | true to finalize straight away. |
send, send_via | send: true finalizes and delivers it. send_via is ["email"] (default), ["sms"] or both. |
curl "https://morzepay.com/v1/invoices" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-order-1001" \
-d '{
"customer": { "name": "Sarah Namuli", "email": "[email protected]", "phone": "0772418905" },
"items": [
{ "description": "Website design (5 pages)", "quantity": 1, "unit_amount": 450000 },
{ "description": "Hosting, 12 months", "quantity": 2, "unit_amount": 75000 }
],
"tax_percent": 18,
"days_until_due": 14,
"reference": "order-1001",
"send": true
}'
Response 201
{
"success": true,
"data": {
"id": "inv_UfwyBh1W4Jfa6iG2u2AVT3",
"object": "invoice",
"number": "NILE-0001",
"status": "open",
"customer": { "name": "Sarah Namuli", "email": "[email protected]", "phone": "+256772418905", "address": null },
"lines": [ { "description": "Website design (5 pages)", "quantity": 1, "unit_amount": "450000.00", "amount": "450000.00" }, ... ],
"subtotal": "600000.00", "tax_percent": 18, "tax": "108000.00", "total": "708000.00",
"amount_paid": "0.00", "amount_due": "708000.00",
"due_date": "2026-11-11",
"hosted_invoice_url": "https://morzepay.com/i/3f31d270b9829c826d28f0a4e68d7715",
"invoice_pdf": "https://morzepay.com/i/3f31d270b9829c826d28f0a4e68d7715/pdf",
"receipt_url": null,
"delivery": { "email": true, "sms": null }
}
}
hosted_invoice_url is the payment link: share it anywhere (WhatsApp, SMS, your app). invoice_pdf downloads the PDF without an API key, so you can attach or link it.
Invoice lifecycle
| Status | Meaning | You can |
|---|---|---|
draft | Being prepared. No number, not payable, no hosted page. | Edit, delete, finalize, send |
open | Finalized and waiting for payment. Content is frozen. | Send, void, mark uncollectible, record an outside payment |
paid | Paid on the hosted page, or marked paid. Final. | Download the receipt |
void | Cancelled. Kept for your records; the hosted page says it was voided. Final. | Nothing |
uncollectible | Written off, but the customer can still pay it. | Void, record an outside payment |
past_due is true for open invoices after their due date. To change a finalized invoice, void it and create a new one, like Stripe.
Finalize, send, void
Update a draft. Send only the fields you want to change. On finalized invoices only metadata can change.
Assigns the next number and opens it for payment. Totals must be between UGX 500 and UGX 5,000,000 (the mobile money limits).
Body { "via": ["email", "sms"] }. Emails the PDF with a pay button, and/or texts the payment link. Finalizes drafts first.
Records money you received outside Morzepay (cash, bank): { "paid_out_of_band": true, "note": "Cash at the shop", "email_receipt": true }. The invoice becomes paid and a receipt is issued. Your wallet balance doesn't change.
Deletes a draft. Finalized invoices can't be deleted; void them instead.
List and retrieve invoices
Permission invoices:read. Filters: status, customer_email, reference. Paginate with starting_after (an invoice id). Newest first.
Includes payments: each attempt on the hosted page with its status, payer and transaction_id.
The invoice as a PDF (application/pdf).
Create a checkout session
Permission checkout:write. Like Stripe Checkout: create a session for an order, redirect your customer to its url, and we send them back to your success_url once they've paid. The page handles the phone prompt, waiting, retries and the receipt.
| Field | Description |
|---|---|
line_items or amount | Lines like invoice items, or a single amount in UGX with a title. |
title | What's being paid for. Required with amount; defaults to the first line item. |
description | Optional text under the title. |
success_url | Where to send the customer after paying. {CHECKOUT_SESSION_ID} is replaced with the session id. |
cancel_url | Shown as a back arrow on the page. |
reference | Your order id. Returned on the session and in webhooks. |
customer | Prefill { "name", "email", "phone" }. |
require_email, require_name | Make those fields required on the page. |
allow_custom_amount, min_amount | Let the customer enter the amount (donations, school fees, top-ups). |
reusable | true makes a payment link that accepts any number of payments until you expire it (Stripe Payment Links). Default false: one payment, then complete. |
expires_in_minutes | One-time sessions only. 30 to 10080. Default 1440 (24 hours). |
wallet_id, fee_bearer, metadata | As for invoices. |
curl "https://morzepay.com/v1/checkout/sessions" -X POST \
-H "Authorization: Bearer $MORZEPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-77812" \
-d '{
"title": "Order #77812",
"line_items": [ { "description": "Rolex combo", "quantity": 2, "unit_amount": 7000 } ],
"reference": "order-77812",
"success_url": "https://yourshop.ug/thanks?session={CHECKOUT_SESSION_ID}",
"cancel_url": "https://yourshop.ug/cart"
}'
Response 201
{ "success": true, "data": { "id": "cs_J9WN4dg0YnY6VjMMwq0kL1", "object": "checkout.session", "status": "open",
"url": "https://morzepay.com/c/83513594a9ee455aacf23ca24eeff21b", "amount_total": "14000.00", "reusable": false,
"expires_at": "2026-10-29T09:00:23+03:00", ... } }
Don't trust the redirect alone to ship an order: confirm with the checkout.session.completed webhook or by retrieving the session.
Retrieve and expire checkout sessions
Permission checkout:read. Status is open, complete (paid; see transaction_id and receipt_url) or expired. Includes payments.
Stops a session or payment link from accepting payments.
Receipts
Every payment on an invoice or checkout page gets a numbered receipt, emailed to the payer when we have their address. Receipts are also available for any successful collection.
Permission transactions:read. The last one creates the receipt the first time you ask for it. Each receipt has a public url and pdf you can give your customer.
{ "success": true, "data": { "id": "rcpt_WDL1pxuSr3cCcrFL1im2Th", "object": "receipt", "number": "NILE-R0001",
"amount_paid": "733679.00", "fee": "25679.00", "payment_method": "MTN Mobile Money",
"invoice_number": "NILE-0001", "url": "https://morzepay.com/r/5c0e0f50...", "pdf": "https://morzepay.com/r/5c0e0f50.../pdf", ... } }
Webhooks
Set a notification URL in the dashboard under Developers. We POST JSON to it for these events:
| Event | When |
|---|---|
deposit.succeeded | The customer paid and the wallet was credited. |
deposit.failed | Declined, timed out or cancelled. |
payout.succeeded | The recipient was paid. |
payout.failed | The payout failed and funds returned to the wallet. |
transfer.succeeded | A wallet-to-wallet transfer completed. |
transaction.review | A transaction needs a manual check. |
invoice.finalized | An invoice got its number and can be paid. |
invoice.sent | An invoice was emailed or texted. |
invoice.paid | Paid on the hosted page (includes payment) or marked paid. |
invoice.payment_failed | A payment attempt on the hosted page failed. The invoice stays open. |
invoice.overpaid | A second payment arrived for an invoice that was already paid or voided. Refund it with a payout. |
invoice.voided, invoice.marked_uncollectible | Status changes you made. |
checkout.session.completed | A checkout page was paid (includes payment). Sent for every payment on reusable links. |
checkout.session.expired | A session expired or was deactivated. |
Invoice and checkout payments also send the usual deposit.succeeded for the underlying transaction; its metadata holds the invoice_id or checkout_session_id.
POST /webhooks/morzepay
Morzepay-Event: deposit.succeeded
Morzepay-Delivery: evt_Mx8Qp3VnK7rT2bWc9LhZ4d
Morzepay-Signature: t=1791127650,v1=5e8f0c7a...
{
"id": "evt_Mx8Qp3VnK7rT2bWc9LhZ4d",
"event": "deposit.succeeded",
"created_at": "2026-10-04T14:07:51+03:00",
"data": { "id": "txn_Rk3Lp9WmQ2vX7bN4cTz8Hd", "status": "success", "net": "10000.00", ... }
}
Events are sent immediately after the transaction changes. Use Send test event under Developers in the dashboard to check your endpoint: it sends a ping event and shows exactly what your server answered.
Respond with any 2xx within 10 seconds. Otherwise we retry after 1 min, 5 min, 15 min, 1 h, 2 h, 6 h, 12 h and 24 h. The same event can arrive more than once, so store the event id and ignore repeats. Redirects are not followed, and URLs must be public https:// addresses.
Verifying signatures
Always verify the signature before trusting a webhook. Compute HMAC-SHA256 of t + "." + raw_body with your signing secret and compare it to v1 in constant time. Reject timestamps more than 5 minutes old.
<?php
$secret = getenv('MORZEPAY_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // raw body, before json_decode
$header = $_SERVER['HTTP_MORZEPAY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig); // ['t' => ..., 'v1' => ...]
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// Deduplicate on $event['id'], then update your order using $event['data']['reference'].
http_response_code(200);
Treat the webhook as a signal: for high-value actions, confirm with GET /v1/transactions/{id} before releasing goods.