Payments

Payment webhooks

Payment events use the same signed, retried webhook pipeline as payouts. Subscribe an endpoint to payment.* events in Developers → Webhooks.

Events#

EventWhen
payment.createdA payment intent was created (API, dashboard, payment link or checkout session).
payment.submittedA signature was reported or seen. Informational: not proof of payment.
payment.confirmedVerified on chain at confirmed commitment. Fulfil here.
payment.finalizedThe settlement transaction reached finalized commitment.
payment.failedA transfer for this payment was underpaid or overpaid.
payment.expiredNo valid payment before expires_at.
payment.cancelledYou cancelled the payment.
payment.refundedA refund confirmed on chain (full or partial — check amount_refunded and status).

Payload#

Every event uses the standard Muny envelope. data.object is the full payment intent at the time of the event.

json
{
  "id": "evt_5c1e0b6f2d8a4c7e9b13f0a2d4e6c8b1",
  "object": "event",
  "type": "payment.confirmed",
  "created_at": "2026-10-01T14:12:09.311Z",
  "organization_id": "0b8f…",
  "livemode": false,
  "data": {
    "object": {
      "id": "3f1c9a52-7d0e-4c1b-9b8e-6a2f1d5c0e47",
      "object": "payment_intent",
      "status": "confirmed",
      "amount": "249",
      "token": "MUNY",
      "reference": "ORDER-48291",
      "payer_address": "6qZ4…",
      "transaction_signature": "5G6Y91XWA6aw6erE…",
      "fiat_value_at_payment": { "amount": "29.88", "currency": "USD", "price": "0.12", "source": "mock" },
      "metadata": { "plan": "pro", "user_id": "12345" }
    }
  }
}

Handling events#

app/api/webhooks/muny/route.ts
import { verifyWebhook } from "@muny/sdk";

export async function POST(req: Request) {
  const raw = await req.text();
  const event = await verifyWebhook(raw, req.headers.get("muny-signature"), process.env.MUNY_WEBHOOK_SECRET!);

  // Deliveries can repeat (retries, manual resend). Process each event id once.
  if (await alreadyHandled(event.id)) return new Response(null, { status: 200 });

  switch (event.type) {
    case "payment.confirmed":
      await fulfilOrder(event.data.object.reference);
      break;
    case "payment.refunded":
      await recordRefund(event.data.object.reference, event.data.object.amount_refunded);
      break;
    case "payment.expired":
      await releaseInventory(event.data.object.reference);
      break;
  }
  await markHandled(event.id);
  return new Response(null, { status: 200 });
}
  • Verify the Muny-Signature header against the raw body. The scheme is the same as for payouts; see Webhooks.
  • Dedupe on event.id (also sent as Muny-Event-Id). Retries and manual resends deliver the same event id.
  • Respond 2xx quickly and do slow work asynchronously. Failed deliveries retry with exponential backoff, up to 8 attempts.
  • Events can arrive out of order. If order matters, retrieve the payment and act on its current status.

confirmed or finalized?#

Fulfil on payment.confirmed for most goods. Confirmed transactions on Solana are very rarely rolled back. For high-value or irreversible fulfilment, wait for payment.finalized, which usually follows within seconds.

Each payment's detail page in the dashboard lists its webhook deliveries with status and response, and you can resend any of them. The same list is available via GET /v1/payments/:id/events.