Skip to main content
The payout pipeline is the path from a placed order to money in a seller’s account. @mercurjs/core ships the building blocks: the workflows that decide and send payouts, the webhook route and subscriber, and the provider interface. It doesn’t ship a scheduler. The Stripe connector plugin brings one, and without it you call the workflows yourself.

What ships in @mercurjs/core

The core plugin gives you every piece that talks to the provider or writes payout records:
  • Account workflows: createPayoutAccountWorkflow, createOrRefreshSellerPayoutAccountWorkflow, createOnboardingWorkflow, and syncPayoutAccountWorkflow
  • Payout workflows: createOrderPayoutsWorkflow, createPayoutReversalsWorkflow, processPayoutWorkflow, and processPayoutReversalWorkflow
  • Webhook route: POST /hooks/payout keeps the raw body and emits payout.webhook_received with a 5000 ms delay and 3 attempts
  • Webhook subscriber: payout-webhook parses the event through the provider and runs processPayoutForWebhookWorkflow
  • Admin and vendor routes: payout accounts (sync, refresh-link, map an existing provider account) and payouts (list, detail, POST /admin/payouts/:id/retry)
  • Providers: the built-in system provider, plus @mercurjs/stripe-connector-plugin as a separate package

What decides when payouts run

No scheduled job or subscriber in core calls createOrderPayoutsWorkflow or createPayoutReversalsWorkflow. Core has no jobs directory. The Stripe connector plugin owns the schedule. It ships seven jobs, payout-sync-onboarding, payout-validate-captures, payout-process-transfers, payout-process-reversals, payout-process-settlements, payout-alert-failed-operations, and payout-monitor-kyc, plus subscribers that run a payout pass on fulfillment, cancellation, payment capture, and refunds. It also owns settlements, operator adjustments, job runs, checkpoints, and the operator notification log, in its own stripe_connector module. See Stripe Connect. If you build on another provider, schedule the workflows from your own job or subscriber.

How createOrderPayoutsWorkflow decides

createOrderPayoutsWorkflow takes order_ids and an optional required_fulfillment_status (default fulfilled). For each order it loads the total, the fulfillments, the seller’s payout account, the payment, and the commission lines, then decides: When every check passes, the payout is pending and the workflow sends it to the provider in the same run. The amount is the order total minus its commission lines. For an order with a total of 47.00 and 4.70 in commission lines, the payout amount is 42.30. A refund that lands before the payout is deducted from it, with its share of the commission. The reversal pass then skips that refund. Each order gets at most one payout, because order_id is unique on payout. Running the workflow again re-decides payouts that are on_hold or failed and leaves paid, processing, and aborted ones alone, so it’s safe to call on a timer or from several subscribers.

How reversals work

createPayoutReversalsWorkflow takes order_ids. For every refund on an order whose payout exists, it writes one payout_reversal, the way Medusa keeps a Refund under a Payment:
  • The amount is the refund minus its share of the commission: refund − commission × refund / order total
  • The reversal waits on_hold until the payout is paid
  • A refund already deducted from the payout is skipped
The workflow then calls the provider’s reversePayout. Running it again leaves reversals that are already paid alone.

Trigger payouts from your own code

Without the Stripe connector plugin, decide when an order is ready yourself. This subscriber runs a payout pass when Medusa emits order.fulfillment_created:
src/subscribers/payout-on-fulfillment.ts
A partially fulfilled order gets an on_hold payout that the next pass re-decides. A payment captured after fulfillment is picked up the same way, so pair the subscriber with a scheduled job that retries on_hold and failed payouts.

The provider interface

Every external call goes through IPayoutProvider from @mercurjs/types. The module loads exactly one provider and throws Payout module should be initialized with exactly one provider otherwise. Write a provider as a class that implements IPayoutProvider and has a static identifier; the module registers it under pop_<identifier>_<id>. Throw a PayoutProviderError from createPayout or reversePayout to tell the module what happened:
  • { code: PayoutProviderErrorCode.REFUSED } when the provider rejected the request and created nothing, such as a validation or balance error. The row is failed and the retry gets a new idempotency key.
  • { code: PayoutProviderErrorCode.FINAL } (or { final: true }) when a retry can’t succeed. The row is aborted.
  • Anything else, including a plain Error from a timeout, counts as an unknown outcome. The row is failed and the retry reuses the idempotency key, so a payout the provider did make isn’t made twice.
When you register no provider, the module loads system. It returns active accounts and paid payouts without moving money, which suits local development and tests. Register Stripe Connect in medusa-config.ts with the Stripe Connect integration.