PayPal posts every event for your REST app to one URL. The engine serves it at POST ecommerce/webhooks/paypal (route ecommerce.webhooks), and the paypal gateway verifies and maps each delivery. The engine handles deduplication, rate limiting (ecommerce.webhook.inbound), its ap.ecommerce.webhook_received hooks, and reconciliation of checkouts the shopper never finished in the browser.
Because the paypal gateway implements the engine's SharesWebhookEndpoint, one endpoint reconciles checkouts placed with all four gateway keys. A delivery posted to a sibling's URL (ecommerce/webhooks/paypal-card, …) is refused with use_paypal_endpoint.
Setup
php artisan ecommerce-paypal:webhooks:register
Set the printed id as PAYPAL_WEBHOOK_ID. Without a webhook id, every delivery is refused (webhook_id_missing). See PayPal App and Credentials for rotating the webhook, and Artisan Commands for the options.
Run a queue worker for the engine's webhook queue (artisanpack.ecommerce.webhooks.connection and .queue): the reconciliation job and this package's side effects run there.
Subscribed events
ecommerce-paypal:webhooks:register subscribes the webhook to exactly these events:
| Event | Payment outcome reported to the engine | Side effect (queued) |
|---|---|---|
CHECKOUT.ORDER.APPROVED |
None (finalize runs from the browser) | None |
PAYMENT.CAPTURE.COMPLETED |
succeeded |
Settles a pending capture on a placed order (paypal.capture_completed timeline entry) |
PAYMENT.CAPTURE.PENDING |
None | paypal.capture_pending timeline entry with the reason |
PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.DECLINED |
failed, once PayPal confirms it |
For a pending capture on a placed order: records the status, writes paypal.capture_denied, adds a staff note, sets the order's payment status to failed (see Denied pending captures), and fires ap.ecommerce.paypal.captureDenied |
PAYMENT.AUTHORIZATION.CREATED |
succeeded |
None |
PAYMENT.AUTHORIZATION.VOIDED |
failed, once PayPal confirms it |
For the authorization of a placed order (authorize intent): records VOIDED on orders.meta.paypal.authorization, so the daily reauthorization skips it, writes paypal.authorization_voided, and adds a staff note. The order and payment statuses aren't changed |
PAYMENT.CAPTURE.REFUNDED |
refunded, with the refund id |
Records a refund made in PayPal on the order, and settles a refund the store issued but recorded as failed (paypal.refund_settled) |
PAYMENT.CAPTURE.REVERSED |
refunded |
Records the reversal as a refund and writes paypal.capture_reversed |
CUSTOMER.DISPUTE.CREATED, .UPDATED, .RESOLVED |
None | Order note, timeline entry, summary on orders.meta.paypal.disputes, and the dispute hooks |
VAULT.PAYMENT-TOKEN.CREATED, .DELETED |
None | Syncs saved payment methods. See Saved Payment Methods |
The session reference is the PayPal Order id: the resource id for CHECKOUT.ORDER.*, and supplementary_data.related_ids.order_id for payment events.
Trust nothing in the body. PayPal signs a CRC of the body, not the body itself. A succeeded outcome only makes the engine run finalize, which loads the Order from PayPal before capturing. A failed outcome is only reported after the capture or authorization is fetched and PayPal confirms it. Every side effect re-fetches its resource (capture, authorization, refund, dispute, or token) from PayPal first.
Verification
Each delivery is verified before anything else. It is refused, with a distinct code, when:
| Code | Cause |
|---|---|
missing_headers |
A PAYPAL-TRANSMISSION-ID, -TRANSMISSION-TIME, -TRANSMISSION-SIG, PAYPAL-CERT-URL, or PAYPAL-AUTH-ALGO header is missing |
unsupported_algorithm |
The algorithm isn't SHA256withRSA |
stale_transmission |
The transmission time is unreadable or further from now than webhook_tolerance_seconds (3 days by default; see Transmission time) |
invalid_cert_url |
The certificate URL isn't HTTPS on PayPal's certificate host for the mode (api.paypal.com live, api.sandbox.paypal.com sandbox), at a /v1/notifications/certs/CERT-… path |
webhook_id_missing |
No webhook id is configured |
invalid_payload |
The body isn't a PayPal event |
certificate_unavailable |
The certificate couldn't be downloaded (PayPal redelivers) |
certificate_expired |
The certificate is outside its validity dates |
certificate_untrusted |
The certificate can't be read or wasn't issued to PayPal |
signature_mismatch |
No configured webhook id verifies the signature |
verification_unavailable |
postback mode: PayPal's verification endpoint couldn't be reached (PayPal redelivers) |
use_paypal_endpoint |
Posted to a sibling gateway's URL |
local (default)
The certificate is fetched through the PayPal client (TLS-verified), checked, and cached until it expires. A certificate that couldn't be downloaded or used is remembered for 10 minutes, so unsigned requests can't make the store download it again and again. The signature over transmission_id|transmission_time|webhook_id|crc32(body) is checked with OpenSSL for each configured webhook id. No API call per event, and it keeps working while PayPal's API is degraded.
postback
PAYPAL_WEBHOOK_VERIFICATION=postback
Each delivery is sent to PayPal's POST /v1/notifications/verify-webhook-signature instead, with the event exactly as it was received, so PayPal decides. With several webhook ids, each is tried in turn. It costs one API call per event, and deliveries are refused (and redelivered by PayPal) while that endpoint is unreachable.
A failure logs its code and the transmission id (and PayPal's debug id when there is one), never the body.
Transmission time
webhook_tolerance_seconds (PAYPAL_WEBHOOK_TOLERANCE_SECONDS) is how far a delivery's PAYPAL-TRANSMISSION-TIME may be from now. It defaults to 259200 (3 days), PayPal's redelivery horizon, so PayPal's own retries of a delivery your app missed aren't refused as stale. 0 turns the check off, and a non-numeric value falls back to the default. A long window doesn't let a replay act twice: the signature covers the transmission id and time, and the engine drops event ids it has already processed.
Processing
- The engine verifies the delivery through the
paypalgateway, dedupes it, and queues reconciliation for a payment outcome. - For a new, verified delivery, the engine fires
ap.ecommerce.gateway.paypal.webhook_received. The package then:- queues
ProcessPayPalWebhookfor events with side effects. The job carries only the event id, type, and resource id, never the payer's personal data, and retries for up to 24 hours while PayPal is unreachable (after 30 s, 2 min, 10 min, and 30 min, then hourly). A job that gives up is logged aterroron the package's log channel (logging.channel) with the event id and type, the resource id, and PayPal's debug id, so you can find the event in PayPal and resend it; - fires
ap.ecommerce.paypal.webhookEvent(string $eventType, array $event, WebhookResult $result)for every verified event, whether or not the package acts on it. A listener that throws is reported and doesn't fail the delivery.
- queues
Denied pending captures
A PENDING capture (an eCheck or a review hold) places the order as paid. When PayPal later denies it, the package fails the order's payment through the engine's PaymentOrchestrator::recordProviderPaymentFailure() (engine 1.3): payment_status becomes failed, with a payment.capture_failed timeline entry (source: provider), the engine's ap.ecommerce.payment.failed hook, and its PaymentFailed event. The engine only does this while the order is paid and nothing has been refunded; otherwise reconciling it is up to staff. The order status, stock, and digital deliverables aren't changed; that's store policy, which you can apply from ap.ecommerce.paypal.captureDenied.
Capture events are applied under the order's lock, so events processed out of order can't move a settled capture back to PENDING, and a settle that arrives before the capture was recorded isn't lost.
Disputes and reversals
Dispute events are matched to an order through the disputed capture ids; disputes about captures no order recorded are ignored. For a match, the package adds a translated system note with the reason, amount, respond-by date, and a Resolution Center link; writes a paypal.dispute_created, _updated, or _resolved timeline entry; keeps a summary on orders.meta.paypal.disputes.{dispute id}; and fires ap.ecommerce.paypal.disputeCreated, disputeUpdated, or disputeResolved (Order $order, array $dispute) once those writes have committed. A listener that throws is reported, not rethrown, so a retry doesn't write the note and timeline entry again.
Disputes don't change the order status. That's store policy: apply it from the hooks or a kanban automation.
use ArtisanPackUI\Ecommerce\Models\Order;
addAction( 'ap.ecommerce.paypal.disputeCreated', function ( Order $order, array $dispute ): void {
Notification::route( 'mail', config( 'mail.from.address' ) )->notify( new DisputeOpened( $order, $dispute ) );
}, 10, 2 );
A reversal (a chargeback PayPal settled against you) is recorded as a refund. When the engine refuses to record a refund (an over-refund or a currency mismatch), a paypal.refund_not_recorded timeline entry is written for staff to reconcile.
See the Hooks Reference for every argument.
Testing locally
PayPal can't reach localhost. Use a tunnel and register it with --url:
php artisan ecommerce-paypal:webhooks:register --url=https://abc123.ngrok.app/ecommerce/webhooks/paypal
The PayPal developer dashboard's Webhooks simulator sends sample events that aren't signed for your webhook id, so expect them to be refused (signature_mismatch). Trigger real events by paying in the sandbox instead. PayPal's Webhooks events page in the dashboard lists every delivery and lets you resend one.

