Rethinking Payment APIs at Merpay: The Exchange Abstraction

This article is the 19th entry in Merpay & Mercoin Tech Openness Month 2026.

Background

We are the Payment Platform team at Merpay. Our systems sit beneath almost every movement of money in the Merpay ecosystem — customers paying partners, users transferring to one another, balance top-ups, partner settlements, and more. However different these flows look on the surface, their essence is the same: moving a user’s money through our system to a partner or payee.

So far, we have focused on how to achieve consistency in a distributed system, how to make every payment traceable, and how to compensate when failures occur. These are indeed crucial for end users and can determine the success or failure of a payment company. But today we want to discuss another problem: as a Payment Platform, how can we make the core payment system more general-purpose, onboard new use cases faster, and provide a more seamless developer experience?

This question matters more every year, because the platform keeps growing. Each new product brings a new flow, a new integration, and often a new API, and the cost of that sprawl is quietly paid by everyone who touches the system. In the rest of this article, we analyze the problems this sprawl has created, dissect the relevant parts of our system design to show where they come from, walk through our proposed solution, and describe the outcomes we expect once it is in place.

Problems

In the Payment service, our API design currently distinguishes flows by the source and destination of funds. For example: when a normal user pays a Partner, we use the payment API; when a user tops up a balance account, we use the top-up API; when users transfer to each other, we use the transfer API; when Partners settle between each other, we use the Partner transfer API; and so on. This kind of separate design introduces lots of new terms and concepts, leading to the following problems:

  1. Confusion over which API to use: every time a team integrates a new use case or onboards a new service, the first question is which API they should call — and the answer is rarely obvious. As a result, the Payment team has to be deeply involved in nearly every product design, spending substantial time explaining the same system to different teams.
  2. Unclear responsibilities: many use cases have multiple possible solutions. If API A+B can implement a feature, then B+C might also work. Each time we design for a new use case, disagreements arise within the Payment team.
  3. Too much case-specific logic: some APIs add branching logic based on specific attributes of a use case and introduce lots of hard-coded handling. Once the client changes those characteristics or new use cases appear, Payment must modify the service accordingly; the contracts between services are not robust enough.
  4. Accounting becomes difficult: Due to accumulated tech debt and differing implementations across APIs, the Accounting team may even need to understand which API a particular fund movement used. That forces Accounting to participate in system design, which violates the principle of separation of concerns.

These are only a few representative problems; in real development there are many smaller ones as well, such as difficulty onboarding new team members. So how should we solve these problems?

What is an Exchange?

As discussed above, payment is fundamentally about moving assets from one place to another. In our external APIs, if we can let callers focus most of their effort on deciding who is the source and who is the target, the problem can be greatly simplified. Therefore, we propose a brand-new concept: Exchange.

In our new approach, an Exchange represents a movement of assets. Note that “one movement” here may be a collection of multiple asset movements to multiple destinations. It boils down to two questions:

  1. Source and Target: who is moving assets to where?
  2. What accounting information needs to be sent to accountants?

Under the hood, an Exchange always moves through a two-phase lifecycle — Authorize (reserve the funds at the source) and Capture (actually move them to the destination) at the moment (more lifecycle to be added in the future). Some value types may collapse the two, but the model always keeps both phases so that every Exchange can be reasoned about uniformly. We’ll come back to this when we discuss a Value’s lifecycle.

With this, a caller using our API only needs to answer the questions we mentioned above: who the source and destination are, and what accounting information to send. As for how the money flows internally within Payment, how many external calls are involved, how compensation is handled on failure, etc.—the caller no longer needs to worry about those details.

In addition, we introduce a complementary concept, ExchangeReversal. This is because some payment methods can only be used as a source, but not as a target (for example, we cannot move funds to a customer’s credit card). However, such payments are still reversible in the real world (e.g. refunds). Thus, ExchangeReversal naturally represents “rolling the assets back along the original path.”, and is dedicated to handling such asymmetric business scenarios.

Design Philosophy

Before implementation, one question kept bothering us: how broad should the scope of Exchange be? Should it retrofitting the entire existing system, or should it be designed only for future use cases?

Our ultimate goal is to reduce the coordination cost between the Payment team and its client teams when integrating with Payment service APIs. If we set the scope too large, building Exchange itself becomes painful for developers, because they must consider all existing case-specific logic and technical debt. If we set the scope too small, the abstraction loses its value and cannot cover most scenarios.

In the end, we concluded that Exchange should start “small and stable,” and gradually absorb existing logic after it becomes solid. In its initial phase, Exchange is responsible for the simplest fund movements, and the infrastructure beneath them, that are the step orchestration, atomicity, state machines, and compensation. From there it grows gradually, whenever a new use case appears, we first consider how to implement it via Exchange, and whether we can abstract and absorb some legacy API handling into Exchange. Throughout this process, the legacy APIs continue to serve callers as before.

The flip side of that scope is what Exchange leaves out deliberately. Use-case policy, such as risk checks, fees, side effects, stays with each use case, and we’ll see how it plugs back in when we reach Future Extensions below.

The Anatomy of an Exchange

So what does an Exchange contain? From top to bottom, we have the following logical structure:

Exchange  (a movement of assets: who → who, how much, what state)
  └── ExchangeDetail (1:N; one Exchange can be split into multiple line items)
      ├── Source Value  + accounting code
       └── Dest   Value  + accounting code

At the Exchange level, we only care about high-level information: as described above, who the source/destination is, the total amount, current status, idempotency key, etc. The actual asset movement is described at the ExchangeDetail level.

You might ask: why do we need the detail layer? Why not just attach source and destination directly under Exchange? As mentioned, one Exchange is not necessarily “one-to-one.” It may be a collection where multiple assets flow to multiple destinations. A typical example is split payment: a user pays with part balance + part points, or one payment is settled to multiple Partners. Each ExchangeDetail describes one pair of source → destination:

message ExchangeDetail {
  // source side: what type of value, and what configuration
  ValueType source_value_type = 199;
  oneof source { ValueBalance.Source balance_source = 100; }

  // dest side: same as above
  ValueType dest_value_type = 299;
  oneof dest { ValueBalance.Dest balance_dest = 200; }

  // accounting codes for each side (direction- and operation-specific)
  repeated AccountingCode source_accounting_code = 1;
  repeated AccountingCode dest_accounting_code   = 2;
}

Here we would like to highlight the accounting code part. Remember the fourth problem: Accounting needs to fully understand system APIs. In this design we try to abstract fund movement into a clean model: debit/credit parties are represented by source and destination, and by the Exchange lifecycle we split into Create / Capture / Cancel. The caller passes in these codes, and Exchange simply forwards them to the accounting system at the appropriate time.

In other words, Payment no longer needs to hard-code accounting codes between the business side and the accounting system, and accounting subjects finally return to where they belong.

message AccountingCode {
  enum Type { CREATE = 1; CAPTURE = 2; CANCEL = 3; REIMBURSE = 4; }
  string accounting_code = 1;
  Type   type            = 2;
}

The Value Concept

The Value repeatedly appearing in ExchangeDetail is the most critical piece of the entire design.

So far, we have had a long-term pain point: even for the same balance type, we explicitly distinguish message types when used as source vs. when used as destination. In the legacy design, as a source it’s called PaymentMethod, and as a destination it’s called UserBalanceItem. This causes the same thing to be split into two mechanisms in code just because it appears in different places, and they must be handled separately.

So we unify them into the Value concept: a Value represents any kind of money pool that can hold funds. It can be the source of money or the destination of money. A Value is also hierarchical; it may have subtypes. For example, we have BalanceValue, and beneath it we may further subdivide into FundsValue or PointsValue, etc.

More importantly, each Value comes with a set of attributes, for example:

Value Ca be used as Source? Can be used as Destination? Is Reversible?
Funds Yes Yes Yes
CreditCard Yes No Yes
Bank Yes Yes Yes

In code, all Values implement the same interface, so the top level Exchange workflow does not need to know whether it’s dealing with balance or credit card:

// pseudocode: shared interface for all value types
type ExchangeValue interface {
    GetID() string
    GetValueType() ValueType   // BALANCE / CREDIT_CARD / ...
}

But another important problem still remains: even if we abstract the Value concept, we still need to figure out how to manage the Value when even for the same value, its behavior may differ by its current position and stage? In the existing payment system, we can roughly divide it into the following stages:

Source Destination
Authorize Lock the source asset Add balance pre-processing, etc.; in some cases no action
Capture Deduct the locked asset Increase the asset

Each Value only needs to implement one handler for each of these behaviors and register them into the system (Value Manager). Then the Exchange workflow only routes to the specific handler according to the Value type and executes it—without needing to understand the Value’s detailed behavior in a given state. Thus, when new Value types or lifecycle steps are added, we only need to implement the handler and register it into the manager.

Practical Example

To make all of this concrete, let’s follow a single Exchange from end to end: moving ¥1,000 from a customer’s balance to a partner’s sales balance. This is exactly what a Charge (as it is named in our legacy API design) does underneath — but notice that nothing in the request below mentions "Charge." The caller only describes a source, a destination, and the accounting codes for each side.

Caller request (pseudo code):

CreateExchange({
  source_customer: { type: USER,    id: "123" },
  dest_customer:   { type: PARTNER, id: "456" },
  amount: 1000 JPY,
  capture: true,                 // true means capture immediately after auth, in one go
  details: [{
    // source: user’s Funds balance
    balance_source: { balance: { type: FUNDS, amount: 1000 } },
    source_accounting_code: [{ type: CREATE, code: "..." }, { type: CAPTURE, code: "..." }],

    // dest: Partner’s Sales balance
    balance_dest:   { balance: { type: SALES, amount: 1000 } },
    dest_accounting_code:   [{ type: CAPTURE, code: "..." }],
  }],
})

Two things are notable here. First, the caller never has to consider which API is suitable for the use case, they simply name the source and destination Values . Second, every accounting code rides along with the request, so Payment can forward each one at the right lifecycle stage without ever understanding the business and hardcoding anything in its code.

State machine:

CREATED ──auth──▶ AUTHORIZED ──capture──▶ CAPTURED
  │                  │             │
  └──fail──▶ FAILED ◀┘

Create persists the Exchange in the CREATED state, authorization moves it to AUTHORIZED, and capture completes it at CAPTURED. A failure at any point routes to FAILED — but only after compensation has unwound whatever already happened.

The create flow runs on Magician, our in-house workflow engine for fault-tolerant, resumable distributed transactions. Its backbone is deliberately small:

// pseudocode: the backbone of the Create workflow
func Create(ctx, params):
  exCtx := buildExchangeContext(params) // assemble Exchange + Details + Values in memory
  validate(exCtx) // validate structure, amount consistency, limits (≤1,000,000), etc.
  createResource(exCtx) // persist everything in one Spanner transaction, state = CREATED
  markCreated(exCtx)
  authWorkflow(exCtx) // sub-workflow: authorize + (optional) capture

authWorkflow: failure compensation (Saga)

Within authWorkflow lies our handling for “failure compensation.” When authorizing the source for each detail, we register a corresponding compensation action in a Saga (see メルコイン決済基盤における分散トランザクション管理 for more information on Saga) after each successful authorization :

// pseudocode: authorize with saga compensation
saga := NewSaga()
for detail in details:
  authorize(detail.source) // lock source funds
  saga.AddCompensation(cancelSourceAuth, detail) // if later fails, undo in LIFO order
  authorize(detail.dest) // dest direction (no-op in funds scenario)

// if anything fails in the middle:
//   saga.ExecuteWait() ——in reverse order, cancel already-authorized sources
//   exchange.Fail() ——mark as FAILED

Because capture is true, a successful authorization chains straight into the capture phase: for each detail we deduct the locked source and credit the destination, and the Exchange transitions to CAPTURED. With capture: false, it would stop at AUTHORIZED and wait for a separate CaptureExchange call.

This way, we cleanly release the funds locked by the first detail of a split payment with multiple details when/if the second detail fail. We avoid dirty states like “half the money is locked.” What’s worth taking away is that none of this machinery is specific to "balance to sales." The same create-authorize-capture flow, the same Saga, and the same handler routing drive a split payment across multiple details, a cross-type movement such as funds to points, or any new Value we plug in later. The use case changes; the core does not.

Future Extensions

In the earlier “Design Philosophy” we said we should keep case-specific logic in its own place to prevent it from contaminating the core. However, in real applications, we inevitably have case-specific logic — for example, payouts must pass Know Your Customer (KYC) checks, refunds may need to reverse a coupon, or a PartnerTransfer must notify the partner once it completes. Such logic must be placed into the overall flow in some way.

Our approach is to reserve several named extension points (ExtensionPoint) in the workflow (BEFORE_CREATE, AFTER_CAPTURE, ON_FAIL, etc.). Each use case registers its logic by (Usecase, ExtensionPoint). When the core reaches a certain point, it only checks “what is registered for the current Exchange use case here,” and otherwise knows nothing. Based on the “shape” of the logic, we define three kinds of contracts:

  • Hook — a pure function for observation/interception at boundaries (KYC, limits); stateless, no compensation
  • Step — an inserted operation in the middle of the flow with Forward + Compensation (calling external services, writing sibling resources); on failure, the Saga rolls back in order
  • Decision — lets the core choose a branch according to business logic

Where do these Hooks/Steps live? They do not live in the Payment core; instead we place them in an independent shared package. Each product team implements its own piece according to the contract, submits a PR for Payment team review, and at runtime the core routes to the correct implementation by the Usecase enum.

// pseudocode: product teams implement and register extensions in the shared package
registry.Register(USECASE_USER_PAYOUT, BEFORE_CREATE, kycHook)

In this way, business teams can self-serve their special logic. The Payment team only gatekeeps the contracts and quality, while the “small and stable” core never needs to know what use cases are running in production.

(Please note that extensions are merely a draft plan, subject to change)

Conclusion

Back to the question at the beginning: as a developer, which API should a developer use? With Exchange introduced, the answer is simple: you only need to know the source and the target.

We are not trying to build an all-powerful monolith. Instead, we step back and extract a “small and stable” ledger primitive: it does only one thing—move assets—while doing it correctly, atomically, and with compensations. As for risk control, fees, various capture modes, etc., they remain where the teams who understand them best are.

With this new path we hope to see shorter delivery cycles, clearer responsibility boundaries, and no more need for the Payment team to be deeply involved in every product design. And this will culminate to a system that finally puts Accounting at ease, with accounting codes forwarded cleanly end-to-end.

This is only the first step of Next Payment.

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加