Yuno IPN / Webhooks
Summary
Yuno notifies any state change in payments and subscriptions against a webhook URL configured on the Yuno side. The webhook is the only reliable source of state: the user redirect to the site may not occur (tab closed, network error), so no approval, cancellation, or refund decision depends on the browser flow.
Payload Structure
Yuno sends a JSON object with a top-level type_event field that identifies the event (for example payment.purchase) and a data field that wraps the main object. For payment events, the object lives in data.payment; for subscription events, in data.subscription. Each carries its id (or code for subscriptions), its status, and a metadata field. The full webhook object reference is in the Yuno documentation.
Yuno metadata has a specific shape: instead of a flat object, it is an array of objects with key and value. Farfalla always reads it with a helper that iterates the array looking for the key; the rest of the code does not assume any other format. The keys that Farfalla includes when creating the checkout and depends on the IPN to read are order_uuid, tenant_id, and, for subscriptions, transaction_type.
Event Types
Yuno emits events in several families. Only two are routed to a handler in Farfalla; the rest enter the endpoint, leave a row in ipn_records, and, when the job tries to process them, fail with an exception from the manager indicating that the driver does not exist.
| Family | Status in Farfalla |
|---|---|
payment.* | Routed to the payment handler |
subscription.* | Routed to the subscription handler |
enrollment.*, payout.*, onboarding.* | No handler — the job fails when building the manager |
Payment events
All payment events go to the same handler, which ignores the event name and is guided by the status of the payment object inside the payload. This means that a payment.purchase with a failed status is treated the same as a payment.cancel: what drives the decision is the normalized status, not the event verb.
Events observed in production are payment.purchase (the large majority of volume), payment.refund, payment.chargeback, and payment.fraud_screening. The manager also accepts payment.authorize, payment.capture, payment.cancel, and payment.verify, but those events do not arrive in the current flow.
Subscription events
Subscription events also all go to the same handler. What the handler uses is the status of the subscription object, normalized to the internal vocabulary.
| Event | Status carried | Action applied |
|---|---|---|
subscription.create | ACTIVE or CREATED | If the order is pending and the status normalizes to approved, approves the order. CREATED is only considered approved for subscriptions with a free trial |
subscription.active | ACTIVE | Approves the order if it is pending; reactivates if it was cancelled by a previous IPN (see re-approval) |
subscription.pause | PAUSED | Marks the order as paused |
subscription.resume | ACTIVE | Approves the order if it was pending |
subscription.cancel | CANCELED | Cancels the order if it was approved, recording IPN as the reason |
The manager also accepts subscription.complete, but that event does not appear in the current flow.
Status Mapping
For payment events, the handler builds the status by prioritizing the sub_status from the payload over the top-level status. The logic is deliberate: the sub_status describes more precisely the concrete phase of the payment within Yuno (for example, WAITING_ADDITIONAL_STEP when 3DS is pending, or PARTIALLY_REFUNDED when a SUCCEEDED hides a partial refund). For subscription events, the handler uses the status directly.
The resulting value is normalized against Farfalla's internal vocabulary.
| Yuno status (top-level or sub_status) | Internal status |
|---|---|
SUCCEEDED, ACTIVE, APPROVED, COMPLETED | approved |
CREATED (only for subscriptions with free trial) | approved |
PENDING, PROCESSING, IN_PROGRESS | pending |
PAUSED | paused |
CANCELED, CANCELLED | cancelled |
FAILED, REJECTED, ERROR | error |
REFUNDED, PARTIALLY_REFUNDED | refunded |
DISPUTE_LOST, CHARGEBACK | dispute_lost |
| Any other status | pending (fallback) |
Yuno emits several statuses not explicitly mapped in the internal enum: DECLINED (the bulk of rejected payments), EXPIRED, READY_TO_PAY, IN_DISPUTE, sub-statuses such as WAITING_ADDITIONAL_STEP or ENROLLMENT_ERROR. All fall to the fallback and are persisted as pending. The decision is intentional: pending is the status Farfalla uses for intermediate or uncertain results, where the charge is not yet considered either approved or lost. Keeping them in pending avoids approving access by mistake and avoids premature cancellations until a subsequent event arrives with a definitive status.
The sub-status PENDING_PROVIDER_CONFIRMATION accompanying a payment.refund with status REFUNDED is handled separately: the payment handler explicitly skips processing and leaves it to the pending refund verification job. More detail in Yuno Refunds — Async flow.
Background Processing
Once the row is persisted in ipn_records, the controller dispatches a job to the high-priority queue with a short delay that depends on the event family.
| Event type | Delay |
|---|---|
payment.purchase | 45 seconds |
Other payment.* | 55 seconds |
subscription.* | 20 seconds |
| Others | 60 seconds |
The delay exists to avoid races with the synchronous checkout flow: if the user completes the redirect and the frontend fires the confirmation to the backend, that flow writes the payment before the webhook tries to do so. The job also uses WithoutOverlapping by row ID with a 180-second expiry, so that two dispatches of the same record do not overlap.
The job has tries = 1. Any exception during processing leaves the row with processed = false and logs the error in activity_log. There is no automatic retry: correction requires manual intervention or a command that iterates unprocessed records and requeues them.
Re-approval of an Already Cancelled Order
There is a specific case in subscription events worth keeping in mind. If a previous IPN cancels the order (for example subscription.cancel or three consecutive failed payments) and then a subscription.active or equivalent arrives with an approved status, the subscription handler does not simply ignore the event: it re-approves the order and clears the cancelled_by, cancelled_by_id, and valid_to fields so that the UserPlan becomes active again.
The condition is strict: it only applies if the previous cancellation was marked with cancelled_by = 'ipn' and the UserPlan has no refund_status recorded. A cancellation originated by the user or an admin is not reactivated by a gateway event, and an order with a refund is not reactivated either even if the subscription becomes active again in Yuno. This covers the scenario of a transient failure in a recurring charge that pauses the subscription and recovers when the next attempt succeeds.
Local Cancellation Due to Failed Attempts
The payment handler also triggers a cancellation when it detects that a recurring charge has accumulated three consecutive failures. At that point, in addition to marking the payment as error, it calls the gateway to pause the subscription in Yuno and cancels the order locally with the IPN reason. The local cancellation is what allows a subsequent subscription.active to trigger the re-approval described above: it leaves the order cancelled but with the correct flag to reactivate.
Activity Logs
Webhooks leave a trace in activity_log under several channels. HTTP-level events (reception, deduplication, payload without ID, order not found) go to yuno_webhooks. Changes to payments and orders applied by the job go to payment_event. If the job makes a call to the Yuno API to resolve the event (for example, querying the original payment during a refund) and that call fails or records an observation, that trace goes to yuno_api. Cancellations due to failed attempts are logged with the payment detail and the subscription status at the time of the decision.
| Situation | Channel and message |
|---|---|
| Payload without extractable ID | yuno_webhooks — webhook missing event ID |
| Duplicate webhook | yuno_webhooks — repeated IPN from Yuno arrives |
| Order not found and no tenant | yuno_webhooks — Order not found and tenant_id not available |
| Failed job | IPN activity log — IPN processing failed |
| Refund pending from provider | IPN activity log — skipped, handled by VerifyPendingRefunds |
| Cancellation due to 3 failures | payment_event — Subscription cancelled because of 3 failed payment tries |
Edge Cases
Events from unrouted families. Yuno may send enrollment, payout, or onboarding events to the same URL. The controller accepts them and persists the row in ipn_records, but the manager does not know them. When the job tries to build the driver to process them, it throws an exception and the row is left with processed = false. No harm is done: it simply accumulates rows that require cleanup if Yuno starts emitting those events in production.
Events without event_id. If Yuno sends an event without the identification field corresponding to its family (for example a payment.* with an empty data.payment.id), the ipn_id cannot be constructed and the webhook is discarded with a log entry before being persisted. Yuno receives a 200 and does not retry.
Order not found. The handler logs the order_uuid and payment_id attempted, the row is left with order_id = null and processed = false. There is no automatic retry or proactive alert: it depends on manual log review or general health checks.
Webhook arriving before the synchronous response. If Farfalla is waiting for the HTTP response from a Yuno endpoint and the webhook for the same event arrives first, the job processes the webhook and writes the payment. When the synchronous response arrives, the payment existence checks prevent duplicates. The trace is left in the log for reconciliation.
Job without retry. Any uncontrolled exception during processing leaves the row marked as unprocessed. If the problem was transient (timeout against Yuno, network error during an additional fetch), the row is left behind and requires manual redispatching. A periodic pass that iterates processed = false rows with a certain age and requeues them would be the natural solution, but it does not exist today.
Code References
| File | Responsibility |
|---|---|
app/Http/Controllers/Commerce/PaymentsIpnController.php | Webhook reception, initial persistence, metadata reading, job dispatch |
app/Domains/Commerce/Jobs/ProcessIpn.php | Background processing, queue, overlap control |
app/Managers/YunoEventManager.php | Mapping of type_event to the corresponding handler |
app/Domains/Commerce/Gateways/Yuno/Events/Payment.php | Actions for payment.* events, payment-level deduplication, skip of pending refund |
app/Domains/Commerce/Gateways/Yuno/Events/Subscription.php | Actions for subscription.* events, normalization with free trial |
app/Domains/Commerce/Gateways/Yuno/YunoBaseService.php | Construction of ipn_id, reading of event_id by family |
app/Domains/Commerce/Gateways/Yuno/YunoPaymentStatus.php | Status enum, grouping helpers |
app/Domains/Commerce/Services/PaymentService.php | Payment persistence, approval/cancellation decision, cancellation due to 3 failures |
app/Domains/Commerce/Services/UserProductService.php | Order re-approval after an approved IPN following an IPN-triggered cancellation |
app/Domains/Commerce/Models/IPNRecord.php | Model for the persisted row |