You call the payments API, you get back a 200, and you move on. But the payment isn't done. Somewhere downstream a processor is still deciding and seconds or hours later it will report what actually happened, over a channel you may not control, in a message signed with a scheme that the processor invented, using a vocabulary no other processor shares.
That return trip, knowing when a payment finished, what its outcome was, and whether the message reporting it is even real is the part of payments most merchants never see. It's also where a payment orchestrator earns its keep.
Take a single event: a payment succeeded. Stripe calls it payment_intent.succeeded. Adyen calls it AUTHORISATION and hides the outcome in a success string. Checkout calls it payment_captured. Each arrives on its own channel, signed in its own way, and means "done" in a slightly different sense. Now multiply that by the 100+ processors Hyperswitch connects to. The orchestrator's job is to make every one of them land at your endpoint as a single event you can trust and act on.
Hyperswitch does this with two enumerations. IncomingWebhookEvent captures what happened: a payment succeeded, a refund completed, a dispute opened. AttemptStatus captures where the payment now sits: authorizing, authorized, charged, voided, failed. Getting any processor's response to land cleanly on those two enums takes four layers of normalization: channel, signature, event, and status.
Channel normalization
A payment doesn't end when the merchant submits it. It ends when the processor reports back. That report arrives over one of two channels.
Polling. Hyperswitch repeatedly calls the processor's GET /payment/:id endpoint on a schedule, asking whether the payment has reached a terminal state. The orchestrator owns the timing, the back-off curve, and the retry logic. It is simple to reason about: outbound HTTP only, no inbound endpoint to secure, works behind firewalls and NAT. The cost is latency, which is bounded by the polling interval, and volume, since most calls return no change.
Webhooks. The merchant registers a public HTTPS endpoint with the processor. When the payment transitions, the processor sends a signed POST to the endpoint. It is near real-time, with one request per state transition rather than one per poll. The cost is the auth surface: every processor has its own signature scheme, retry policy, and idempotency model. A missed or misverified webhook leaves a payment in an unknown state.
A payment orchestrator cannot choose between them. Different processors expose different channels with different reliability profiles. Some do not ship webhooks for all transitions. Some retry for three days after a delivery failure; some give up after one attempt. Some require a callback to verify the inbound webhook itself.
| Processor | Webhook channel | Polling endpoint | Default in Hyperswitch |
| Stripe | Yes, signed | Yes | Webhook with PSync reconciliation |
| Adyen | Yes, signed | Yes | Webhook with PSync reconciliation |
| Worldpay | Yes, signed | Yes | Webhook with PSync reconciliation |
| Braintree | Yes, IP allowlist plus HMAC | Yes | PSync-primary |
That single fact, that the channel itself is not uniform, drives a significant part of the orchestration design. Hyperswitch consumes both channels through the same IncomingWebhook trait and routes both through the same downstream business logic, so that merchants see one canonical event regardless of how it arrived.
Signature normalization
The first thing the orchestrator has to do with an inbound webhook is decide whether to believe it. Each processor answers that question its own way.
| Processor | Auth method | Signature source | Message format |
| Stripe | HMAC-SHA256 | Stripe-Signature header, v1 field | {timestamp}.{body} |
| Adyen | HMAC-SHA256 | Body field additionalData.hmacSignature | HMAC-SHA256 |
| Checkout | HMAC-SHA256 | cko-signature header, hex-encoded | Raw request body |
| Braintree | HMAC-SHA1 | Body field bt_signature, pipe-delimited with public key | bt_payload field |
| Worldpay | HMAC-SHA256 | Event-Signature header, last segment after , and / | Raw request body |
| Square | HMAC-SHA256 | x-square-hmacsha256-signature header | x-square-hmacsha256-signature header |
| Nuvei | SHA256, not HMAC | Body or header, varies by webhook type | Concatenated specific fields |
| PayPal | Verified via separate API call | N/A | N/A |
Hyperswitch encodes these differences in a per-connector implementation of the IncomingWebhook trait at crates/hyperswitch_interfaces/src/webhooks.rs:140. Each connector implements the trait's verification primitives: choosing the algorithm, fetching the merchant's webhook secret, extracting the signature off the request, and rebuilding the signed message and the trait's default verify_webhook_source() composes them into a single verdict. Connectors with non-standard schemes (Worldpay, PayPal) override verify_webhook_source() directly. The secret-fetch step is load-bearing: as we'll see, it's also the hook that routes unverifiable webhooks to PSync.
A few examples make the spread concrete.
Stripe. The message is {timestamp}.{body}, signed with HMAC-SHA256, and the signature is the v1 field of the Stripe-Signature header (crates/hyperswitch_connectors/src/connectors/stripe.rs:2256).
Adyen. The signature is base64-encoded inside the webhook payload itself, at additionalData.hmacSignature, and the signed message is a colon-delimited concatenation of eight specific fields:
let message = format!(
"{}:{}:{}:{}:{}:{}:{}:{}",
notif.psp_reference,
notif.original_reference.unwrap_or_default(),
notif.merchant_account_code,
notif.merchant_reference,
notif.amount.value,
notif.amount.currency,
notif.event_code,
notif.success
);
//(crates/hyperswitch_connectors/src/connectors/adyen.rs:2017)
Worldpay. The Event-Signature header is multi-segment. The connector splits it by commas and takes the last segment, splits that by /, and takes the last part again. Worldpay also overrides verify_webhook_source() to compute the HMAC manually (crates/hyperswitch_connectors/src/connectors/worldpay.rs:1285).
PayPal. There is no local signature scheme at all. Verification is a callback API call to PayPal's verification endpoint. This case is handled by verify_webhook_source_verification_call() at crates/router/src/core/webhooks/utils.rs:154, where push is verified by pull.
The IncomingWebhook trait is not an abstraction over signature schemes. It is an admission that no single implementation could ever satisfy every processor.
Event normalization
Once a webhook is verified, Hyperswitch maps the processor-specific event to a canonical event in IncomingWebhookEvent, defined in crates/api_models/src/webhooks.rs:10. Beyond payments, the same normalization machinery covers the rest of the orchestrator's surface. IncomingWebhookEvent includes payout lifecycle events (PayoutSuccess, PayoutFailure, PayoutReversed), mandate events for recurring and subscription flows (MandateActive, MandateRevoked), fraud-management outcomes (FrmApproved, FrmRejected), revenue-recovery signals used by the smart-retry engine (RecoveryPaymentSuccess, RecoveryPaymentFailure, RecoveryPaymentPending), and 3DS authentication results (ExternalAuthenticationARes). Each carries the same promise: one canonical event the merchant can switch on, no matter which of 100+ processors produced it or what it called the event.
| Canonical event | Stripe | Adyen | Checkout | Worldpay |
| PaymentIntentSuccess | payment_intent.succeeded | AUTHORISATION with success=true | payment_captured | Authorized |
| PaymentIntentFailure | payment_intent.payment_failed | AUTHORISATION with success=false | payment_declined | Refused |
| PaymentActionRequired | payment_intent.requires_action | N/A | payment_pending | N/A |
| RefundSuccess | charge.refund.updated with status=succeeded | REFUND with success=true | payment_refunded | Refunded |
| DisputeOpened | charge.dispute.created | NOTIFICATION_OF_CHARGEBACK | dispute_evidence_required | Disputed |
The simple cases, such as Checkout's payment_captured mapping to PaymentIntentSuccess, are one-to-one. The From<CheckoutWebhookEventType> impl in crates/hyperswitch_connectors/src/connectors/checkout/transformers.rs:2102 is a straight match expression.
The hard cases require context. Adyen's AUTHORISATION event does not tell you whether the payment succeeded. That information lives in a separate success string field on the notification payload. And in dispute flows, the event code interacts with a third field, dispute_status, to determine whether the event is DisputeOpened, DisputeWon, DisputeLost, or DisputeChallenged. The mapping function reflects that:
pub(crate) fn get_adyen_webhook_event(
code: WebhookEventCode,
is_success: String,
dispute_status: Option<DisputeStatus>,
) -> api_models::webhooks::IncomingWebhookEvent
//(crates/hyperswitch_connectors/src/connectors/adyen/transformers.rs:5712)
The canonical event is not a function of event_code alone. It is a function of (event_code, success_flag, dispute_context). Different processors expose different dimensions of that tuple, and the orchestrator's job is to project all of them into one canonical event the merchant can switch on.
Status normalization
The event tells the merchant what happened. The AttemptStatus field on the payment record tells them where it is now. Aligning those two views across processors is the third normalization layer.
AttemptStatus lives in crates/common_enums/src/enums.rs and includes variants like Authorized, Charged, Voided, Failure, Authorizing, and PartialCharged. Every processor's terminal and intermediate states have to land on one of these.
| AttemptStatus | Stripe | Adyen | Worldpay | Checkout |
| Charged | succeeded | Captured | Settled | Captured |
| Authorized | requires_capture | Authorised | Authorised | Authorised |
| Authorizing | processing | N/A | Sent for Authorization | Pending |
| Voided | canceled | canceled | Reversed | Voided |
| Failure | failed | AuthorisationFailed | Refused | Declined |
One row in that table deserves a callout. Stripe's requires_capture does not map to Charged. It maps to Authorized. The funds are reserved but not yet captured, a distinction that matters for split-authorization flows, manual capture workflows, and reconciliation. Hyperswitch's From<StripePaymentStatus> for AttemptStatus impl at crates/hyperswitch_connectors/src/connectors/stripe/transformers.rs:2758 encodes this directly:
impl From<StripePaymentStatus> for AttemptStatus {
fn from(item: StripePaymentStatus) -> Self {
match item {
StripePaymentStatus::Succeeded => Self::Charged,
StripePaymentStatus::RequiresCapture => Self::Authorized,
StripePaymentStatus::Processing => Self::Authorizing,
StripePaymentStatus::Canceled => Self::Voided,
StripePaymentStatus::Failed => Self::Failure,
}
}
}Each processor splits the authorize/capture boundary at a slightly different place. Some treat the split as a state, some as an event, some as both. The orchestrator collapses all of those projections into one merchant-facing state machine.
Handling payment processors with unreliable or missing webhooks
Channel normalization assumes that webhooks always arrive. They do not. Webhooks get dropped, delayed, misverified, or never fire for certain transitions in the first place. A payment orchestrator cannot rely on push alone.
Hyperswitch's polling flow, payment sync or PSync, exists for this reason. It is not a fallback for legacy processors. It is the authoritative reconciliation path that the webhook flow defers to whenever trust degrades.
Two pieces of the codebase make this explicit.
First, polling-as-fallback is baked into the signature verification contract itself. The trait method that fetches the merchant's webhook secret has this comment at crates/hyperswitch_interfaces/src/webhooks.rs:231:
If the merchant has not set the secret for webhook source verification, "default_secret" is returned. So it will fail during the verification step and goes to psync flow.
When a webhook cannot be verified, whether because the merchant has not configured a secret, the signature is malformed, or the processor's verification scheme is not supported locally, Hyperswitch treats the webhook as advisory and re-derives the state by polling the processor. The merchant still receives a callback; the state behind that callback came from PSync rather than from the webhook payload.
Second, for processors that require a callback-style verification, Hyperswitch makes an outbound HTTP call to the processor's verification endpoint as part of webhook processing. That logic lives in verify_webhook_source_verification_call() at crates/router/src/core/webhooks/utils.rs:154. The webhook arrived as push, but the verification is pull.
The polling flow itself is straightforward. A PSync request is constructed from the stored payment record at crates/router/src/core/payments/flows/psync_flow.rs:22, sent to the connector via the same trait-based dispatch as any other flow, and the response runs through the same status normalization pipeline as a webhook would. The canonical event that surfaced to the merchant is identical regardless of which channel produced it.
This is what hybrid means in production. A verified webhook is trusted and consumed in place: Hyperswitch parses the signed payload and runs it through the state machine without calling the processor back (CallConnectorAction::HandleResponse). Only when verification fails due to a missing secret, a malformed signature, an unsupported scheme the flow degrades to a live PSync that re-derives state directly from the processor (CallConnectorAction::Trigger). The unifying element is not the network poll but the PSync operation (PaymentStatus): both paths converge on the same status-normalization pipeline, so the merchant-facing event is identical regardless of which one produced it. Webhooks are the fast path; PSync is the reconciliation backstop the system falls back to the moment trust degrades.
Implementation
What a merchant sees is a single, processor-agnostic webhook payload. Whether the underlying processor reported the event via push, via pull, or via a hybrid of both, the envelope is the same.
{
"merchant_id": "merchant_1234",
"event_id": "evt_01J9X7KQ8F3N2R5W6T8Y9Z0A1B",
"event_type": "payment_succeeded",
"object_reference_id": "pay_abc123",
"content": {
"object": {
"payment_id": "pay_abc123",
"status": "succeeded",
"amount": 4999,
"currency": "USD",
"connector": "stripe",
"connector_transaction_id": "pi_3Q8x9q2eZvKYlo2C0r3aBcDe",
"payment_method": "card"
}
},
"delivery_attempt": 1,
"created": "2026-05-26T17:38:29Z"
}The event_type field is the canonical event the merchant switches on. A short glossary:
| event_type | Triggered when | Recommended merchant handling |
| payment_succeeded | Any processor reports terminal success | Mark order paid, fulfill |
| payment_failed | Any processor reports terminal failure | Release reservation, notify customer |
| action_required | 3DS challenge, redirect, or other customer action pending | Surface the action URL to the customer |
| payment_processing | Payment moved into an asynchronous processing state | Hold reservation; wait for the next event |
| refund_succeeded | Refund settled on any processor | Update ledger, mark refund complete |
| refund_failed | Refund declined or reversed | Surface failure to the customer, retry if appropriate |
| dispute_opened | Chargeback initiated | Begin evidence collection |