Implementing a Multi-Channel Payment Callback Framework: Unified Entry, Verify Adapters, and Compensation
Write one callback endpoint for the first channel and it is the easiest thing you did all week: verify, update the order, return success, thirty lines. Copy it for the second channel and tweak. By the third channel the problems surface: three endpoints, three verification implementations, three response formats, three idempotency implementations, one of which forgot idempotency entirely, another returns 200 on verification failure, and none of them logs the raw payload, so a production incident has no trace to follow.
This article lays out a reusable structure whose idea is simple: isolate what varies, verification and response format, and consolidate what does not, idempotency, state machine and compensation. For single-channel detail, see WeChat Pay APIv3 integration in practice and Alipay Open Platform integration. For idempotency table design, see payment order number design and idempotency tables.
1. Three Signals That the Code Has Spiraled
Refactor when any of these appear:
Signal one: verification logic scattered. WeChat RSA verification, Alipay RSA2 verification and UnionPay certificate verification live in three controllers with three different implementation details. One uses the wrong public key, and reconciliation only discovers it three months later.
Signal two: response formats diverging. WeChat wants JSON, Alipay wants the plain text success, UnionPay wants a specific message. Return any of them incorrectly and the corresponding channel keeps retrying, filling the logs.
Signal three: lost notifications with no trace. No raw payload stored, so the channel says it pushed and you say you never received it, with no way to settle the argument.
2. Unified Entry Design
Every channel's callback points at one route with the channel identified as a parameter:
POST /api/payment/notify/{channel}
The routing layer does only three things: identify the channel, capture the raw payload, hand it to the right handler. The raw payload must be preserved byte for byte, because verification computes over the original bytes and any deserialize-then-reserialize cycle can break the signature.
Route::post('/api/payment/notify/{channel}', function (Request $request, string $channel) {
$raw = $request->getContent(); // raw bytes, never transformed
$handler = app(PaymentNotifyFactory::class)->make($channel);
return $handler->handle($raw, $request->headers->all());
})->withoutMiddleware(['auth', 'csrf']);
Two traps. First, the callback route must exclude authentication and CSRF middleware, otherwise the channel never receives your business response. Second, IP allowlisting is not a substitute for verification, because channel egress addresses change; verification is the correct control.
3. Verify Adapters
Use an interface to isolate the differences, with each channel implementing its own verification and parsing:
interface NotifyAdapter
{
public function verify(string $raw, array $headers): bool;
public function parse(string $raw): NotifyPayload;
public function successResponse(): mixed;
public function failResponse(string $reason): mixed;
}
final class NotifyPayload
{
public function __construct(
public readonly string $orderNo,
public readonly int $amount, // integer minor currency unit
public readonly string $channelTxnId,
public readonly string $eventType, // paid / refunded / closed
public readonly bool $isSuccess,
) {}
}
Each channel now cares about exactly three things: how to verify, how to parse into the shared structure, and how to respond. The business layer never learns which channel exists.
Forcing amount to an integer minor unit inside NotifyPayload is deliberate. WeChat returns cents while Alipay returns yuan, and if the adapter does not normalize, the business layer will eventually contain code where nobody can tell whether 100 means one yuan or one hundred.
4. The Shared Processing Layer
After the adapter, everything follows one flow that all channels share:
public function handle(string $raw, array $headers): mixed
{
$adapter = $this->adapter();
// 1. Archive the raw payload regardless of verification outcome
$log = $this->archive($raw, $headers);
// 2. Verify
if (!$adapter->verify($raw, $headers)) {
$this->alert('notify_verify_failed', $log->id);
return $adapter->failResponse('signature error');
}
$payload = $adapter->parse($raw);
// 3. Idempotency: decided by the unique index
if (!$this->idempotent($payload)) {
return $adapter->successResponse(); // duplicate notification
}
// 4. Cross-check amount and order number
$order = $this->orderRepo->findByNo($payload->orderNo);
if (!$order || $order->amount !== $payload->amount) {
$this->alert('notify_amount_mismatch', $log->id);
return $adapter->failResponse('amount mismatch');
}
// 5. State machine transition
$this->orderService->transit($order, $payload);
return $adapter->successResponse();
}
Note the ordering. Idempotency must precede business processing but follow verification. Writing an idempotency record on a failed verification would let a forged request block the legitimate notification that follows.
5. Response Format Differences Across Channels
This table is where most incidents originate:
| Channel | Success response | Failure response | Retry behaviour |
|---|---|---|---|
| WeChat Pay | JSON containing code: SUCCESS |
JSON with an error code | Multiple scheduled retries |
| Alipay | Plain text success |
Anything else counts as failure | Multiple scheduled retries |
| UnionPay | Specific message format | Specific error message | Scheduled retries |
Three points to internalize: Alipay's response must be plain text, so any framework JSON wrapper counts as failure; the HTTP status code is not the business response, and returning 200 with a wrong body still triggers retries; and respond only after processing completes, never acknowledge first and process asynchronously.
6. Compensation for Lost Callbacks
Callbacks will be lost. Network jitter, service restarts and deployment windows can all prevent delivery. There is only one reliable compensation: scheduled active querying.
// Runs every five minutes
Order::query()
->where('status', PaymentState::PENDING)
->where('created_at', '>=', now()->subHours(2))
->where('created_at', '<=', now()->subMinutes(5))
->chunk(200, function ($orders) {
foreach ($orders as $order) {
$result = $this->channelQuery($order); // query the channel directly
if ($result->isPaid()) {
$this->orderService->transit($order, $result);
}
}
});
Three parameters are rules of thumb: only query orders between five minutes and two hours old, because newer ones may still be in flight and older ones rarely change; batch 200 at a time to avoid triggering channel rate limits; and route query results through the same idempotency and state machine, never around them.
For orders still unpaid after two hours, take the other path: close them actively so a user completing payment on a stale page cannot create a duplicate charge.
7. Monitoring Metrics and Alerts
Four metrics matter once the framework is running:
- Verification failure rate, where a sudden rise means a configuration error or forged requests;
- Duplicate notification ratio, where normal traffic includes some retries and an elevated ratio means your response is wrong;
- Compensation query hits, where more hits mean more lost callbacks;
- Callback latency P99, since exceeding the channel timeout makes it mark the delivery failed and retry, creating a feedback loop.
Set alerts on two conditions: ten consecutive verification failures, which is a configuration incident, and a three-fold spike in compensation hit rate, which is a pipeline incident.
8. Six Problems That Always Appear After Launch
First, the framework swallows exceptions, catching a business error and still returning success, so the channel stops retrying and the order hangs forever. Second, middleware rewrites the raw payload, so verification never passes. Third, the idempotency key omits the event type, so payment success and refund success collide. Fourth, logs record full keys or identity numbers, which is a data security incident. Fifth, the callback route is intercepted by a CDN or WAF, so the channel sees 403 and stops retrying. Sixth, idempotency is implemented in a single-instance memory cache, which evaporates on restart.
The unified recommendation: idempotency must be persisted and decided by a unique index, never by cache alone; raw payloads go into a dedicated archive table with periodic archiving; the callback route is excluded from authentication and protective rules; and exceptions must propagate and raise alerts.
For reconciliation as a backstop, see building a reconciliation automation script. For the full transaction chain, see payment transaction flow basics. For multi-channel aggregation, see aggregate payment and the service provider model. More options are collected under the Payment category.
Source: 16IDC original. Product capabilities, pricing and eligibility requirements follow the relevant providers’ official documentation and contract terms.