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:

https://morzepay.com/v1

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:

ControlWhat it does
Permissionswallets:read, wallets:write, deposits:write, payouts:write, transfers:write, transactions:read, msisdn:validate. A call without the needed permission returns 403 INSUFFICIENT_SCOPE.
WalletsOptionally 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 IPsOptional 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

NumberWhat happens
+256700000002The customer declines (collections) or the payout fails. Funds held for a payout are returned.
+256700000003Stays processing for about 60 seconds, then succeeds. Use it to test polling and webhooks.
+256700000004Not a registered mobile money number: rejected immediately, and fails verification.
Any other valid numberSucceeds 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:

ValueCustomer is asked forWallet receives
walletThe amount you send, e.g. 10,000Amount minus 3.5%, e.g. 9,650
customerAmount plus the fee, e.g. 10,363About 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"
}
HTTPCodeMeaning
401UNAUTHENTICATED, KEY_REVOKEDMissing, wrong, revoked or expired key.
402INSUFFICIENT_FUNDSThe wallet balance can't cover the amount and fees.
403INSUFFICIENT_SCOPE, IP_NOT_ALLOWED, LIVE_NOT_ENABLEDThe key or business isn't allowed to do this.
403WEEKLY_LIMIT_REACHEDThe payout would exceed your 7-day live payout limit. details.remaining shows what's left.
404WALLET_NOT_FOUND, NOT_FOUNDDoesn't exist, or this key can't see it.
409DUPLICATE_REFERENCE, WALLET_NOT_ACTIVEReference already used (the existing transaction_id is in details), or wallet is frozen.
422VALIDATION_ERROR, MODE_MISMATCHA field is wrong (details.field names it), or you mixed test and live wallets.
429RATE_LIMITED, MSISDN_RATE_LIMITEDToo many requests. Wait details.retry_after_seconds.
5xxSERVER_ERROROur 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

GET/v1/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

POST/v1/wallets

Permission wallets:write. Main wallets are created in the dashboard; the API creates sub-wallets under them.

FieldDescription
parent_wallet_id requiredThe main wallet this sits under.
name required2–120 characters.
external_refYour own ID for this customer. Unique per business, so you can look the wallet up later.
deposit_fee_bearerwallet 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

GET/v1/wallets/{wallet_id}

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

GET/v1/wallets/{wallet_id}/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

POST/v1/quotes

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

POST/v1/deposits

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.

FieldDescription
wallet_id requiredWallet to credit.
msisdn requiredPayer's number. +256771234567, 256771234567 and 0771234567 are accepted.
amount requiredWhole UGX, 500 – 5,000,000.
reference requiredYour unique reference, 8–36 characters.
fee_bearerwallet or customer. Defaults to the wallet's setting.
descriptionUp to 100 characters.
metadataUp 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

pendingprocessingsuccess | failed

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

POST/v1/payouts

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

POST/v1/transfers

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.

FieldDescription
from_wallet_id requiredWallet to debit.
to_wallet_id requiredWallet to credit.
amount requiredWhole UGX, 1 – 50,000,000.
reference requiredYour unique reference.
feeOptional fee you charge the sender, in UGX. Debited from the sender on top of amount.
fee_wallet_idWhere the fee goes. Defaults to the sender's main wallet.
description, metadataAs 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

POST/v1/msisdn/validate

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

GET/v1/transactions/{transaction_id}

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.

StatusMeaning
pendingCreated, not yet accepted by the network.
processingWith the network, waiting for the customer or the network to finish.
successDone. The wallet balance has changed by net.
failedDidn't happen. Any held funds are back in the wallet. See failure_reason.
reviewBeing checked by Morzepay. Final status follows.

Create an invoice

POST/v1/invoices

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.

FieldDescription
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 requiredUp to 50 lines: { "description", "quantity", "unit_amount" }. Amounts in whole UGX.
tax_percentApplied to the subtotal, e.g. 18 for VAT. Rounded to the nearest shilling.
due_date or days_until_due2026-11-30, or a number of days. Defaults to your invoice settings (14 days).
wallet_idWhere payments land. Defaults to your main wallet. Use a sub-wallet to keep each branch or client separate.
fee_bearerwallet or customer. With customer, the 3.5% collection fee is added on the payment page and the wallet receives the full invoice total.
memo, footerText shown on the invoice (up to 1000 and 500 characters).
referenceYour own unique id for the invoice, up to 64 characters.
metadataUp to 20 keys. Returned on the invoice and in webhooks.
finalizetrue to finalize straight away.
send, send_viasend: 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

StatusMeaningYou can
draftBeing prepared. No number, not payable, no hosted page.Edit, delete, finalize, send
openFinalized and waiting for payment. Content is frozen.Send, void, mark uncollectible, record an outside payment
paidPaid on the hosted page, or marked paid. Final.Download the receipt
voidCancelled. Kept for your records; the hosted page says it was voided. Final.Nothing
uncollectibleWritten 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

POST/v1/invoices/{invoice_id}

Update a draft. Send only the fields you want to change. On finalized invoices only metadata can change.

POST/v1/invoices/{invoice_id}/finalize

Assigns the next number and opens it for payment. Totals must be between UGX 500 and UGX 5,000,000 (the mobile money limits).

POST/v1/invoices/{invoice_id}/send

Body { "via": ["email", "sms"] }. Emails the PDF with a pay button, and/or texts the payment link. Finalizes drafts first.

POST/v1/invoices/{invoice_id}/void
POST/v1/invoices/{invoice_id}/mark_uncollectible
POST/v1/invoices/{invoice_id}/pay

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.

DELETE/v1/invoices/{invoice_id}

Deletes a draft. Finalized invoices can't be deleted; void them instead.

List and retrieve invoices

GET/v1/invoices?status=open&limit=25

Permission invoices:read. Filters: status, customer_email, reference. Paginate with starting_after (an invoice id). Newest first.

GET/v1/invoices/{invoice_id}

Includes payments: each attempt on the hosted page with its status, payer and transaction_id.

GET/v1/invoices/{invoice_id}/pdf

The invoice as a PDF (application/pdf).

Create a checkout session

POST/v1/checkout/sessions

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.

FieldDescription
line_items or amountLines like invoice items, or a single amount in UGX with a title.
titleWhat's being paid for. Required with amount; defaults to the first line item.
descriptionOptional text under the title.
success_urlWhere to send the customer after paying. {CHECKOUT_SESSION_ID} is replaced with the session id.
cancel_urlShown as a back arrow on the page.
referenceYour order id. Returned on the session and in webhooks.
customerPrefill { "name", "email", "phone" }.
require_email, require_nameMake those fields required on the page.
allow_custom_amount, min_amountLet the customer enter the amount (donations, school fees, top-ups).
reusabletrue 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_minutesOne-time sessions only. 30 to 10080. Default 1440 (24 hours).
wallet_id, fee_bearer, metadataAs 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

GET/v1/checkout/sessions/{session_id}

Permission checkout:read. Status is open, complete (paid; see transaction_id and receipt_url) or expired. Includes payments.

GET/v1/checkout/sessions?status=complete&reference=order-77812
POST/v1/checkout/sessions/{session_id}/expire

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.

GET/v1/receipts/{receipt_id}
GET/v1/receipts/{receipt_id}/pdf
GET/v1/transactions/{transaction_id}/receipt

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:

EventWhen
deposit.succeededThe customer paid and the wallet was credited.
deposit.failedDeclined, timed out or cancelled.
payout.succeededThe recipient was paid.
payout.failedThe payout failed and funds returned to the wallet.
transfer.succeededA wallet-to-wallet transfer completed.
transaction.reviewA transaction needs a manual check.
invoice.finalizedAn invoice got its number and can be paid.
invoice.sentAn invoice was emailed or texted.
invoice.paidPaid on the hosted page (includes payment) or marked paid.
invoice.payment_failedA payment attempt on the hosted page failed. The invoice stays open.
invoice.overpaidA second payment arrived for an invoice that was already paid or voided. Refund it with a payout.
invoice.voided, invoice.marked_uncollectibleStatus changes you made.
checkout.session.completedA checkout page was paid (includes payment). Sent for every payment on reusable links.
checkout.session.expiredA 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.