---
title: "Sell with PayPal"
description: "Verified PayPal purchase-to-license reference with native proof, durable mapping and honest lifecycle boundaries."
slug: "/docs/v1/guides/paypal"
section: "Webhooks and integrations"
order: 76
audience: "all"
level: "advanced"
last_updated: "2026-10-18"
version: "v1"
related: ["/docs/v1/guides/any-store", "/docs/v1/api/idempotency"]
draft: false
---
Connect your own PayPal store to [KDPika licensing](/docs/v1/start/glossary#kdpika-licensing) through a verified, durable backend. This does not add a processor to KDPika [platform subscriptions](/docs/v1/start/glossary#platform-subscription).

## What you will learn

- Configure native notifications and approved [product](/docs/v1/start/glossary#product)/plan mappings.
- Issue once after confirmed payment, then handle supported lifecycle changes.
- Deliver a saved key privately and test without exposing buyer/card data.

## Prerequisites

- Complete the [Sandbox quickstart](/docs/v1/start/quickstart) and [store service setup](/docs/v1/guides/store-service).
- Use your own provider merchant account and private backend credentials; never KDPika's platform billing credentials.
- Have a product-scoped Sandbox management key with [licenses](/docs/v1/start/glossary#license):read and licenses:write.
- Configure one eligible quantity-one line. Mixed baskets, multi-seat quantities and partial refunds need an explicitly reviewed policy.

## Configure the provider

1. Create a PayPal Sandbox REST app for your own merchant and a Sandbox buyer. Save the merchant ID and the [webhook](/docs/v1/start/glossary#webhook) ID registered for this same app.
2. Configure your own HTTPS /provider-event destination for PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, PAYMENT.SALE.COMPLETED, PAYMENT.SALE.REFUNDED, BILLING.SUBSCRIPTION.SUSPENDED, BILLING.SUBSCRIPTION.PAYMENT.FAILED, BILLING.SUBSCRIPTION.CANCELLED, BILLING.SUBSCRIPTION.EXPIRED and [CUSTOMER](/docs/v1/start/glossary#customer).DISPUTE.CREATED/UPDATED/RESOLVED.
3. Set provider/source to paypal, environment to sandbox, seller_id to the merchant ID and webhook_id to the registered webhook ID. Set private KDP_STORE_CLIENT_ID and KDP_STORE_CLIENT_SECRET; leave KDP_STORE_API_KEY empty to obtain an OAuth token automatically. A privately rotated Bearer token is an alternative.
4. One-time orders must contain one purchase unit, one quantity-one item with a mapped sku and one capture. Map the sku in offer_map. Recurring subscriptions map the plan_id instead.
5. Make the capture/subscription payment in your own trusted backend. Do not treat buyer approval, an activated subscription without a paid transaction, or a browser return as collection.

## Verify before granting access

The reference submits the actual webhook event, transmission headers and configured webhook ID to PayPal verify-webhook-signature with its private OAuth token. Only verification_status SUCCESS is accepted. It never downloads an arbitrary cert_url itself. Sandbox and [Live](/docs/v1/start/glossary#live) use distinct fixed API origins and app credentials.

Provider verification, receipt and fulfillment are separate. HTTP 202 only means the authenticated notification was saved. A buyer redirect or successful browser message never creates a license. Only the backend worker can authorize the scoped licensing write after current provider reads.

## Event and lifecycle mapping

| Notification | Confirmed evidence | Licensing decision |
| --- | --- | --- |
| PAYMENT.CAPTURE.COMPLETED | Current completed capture belongs to a completed one-unit order, expected payee and one mapped sku | Issue once by order, retaining capture mapping for refunds/disputes. |
| PAYMENT.SALE.COMPLETED | Active current subscription and matching COMPLETED transaction in authenticated subscription transaction history | Issue initial purchase or renew existing subscription once by sale ID. |
| BILLING.SUBSCRIPTION.SUSPENDED / PAYMENT.FAILED / CANCELLED / EXPIRED | Current subscription status is SUSPENDED / CANCELLED / EXPIRED | Suspend / preserve paid time and record cancellation / suspend. A failure hint while still ACTIVE does not prove suspension. |
| PAYMENT.CAPTURE.REFUNDED | Current completed refund and original capture fully REFUNDED | Revoke mapped license. Partial refunds require review. |
| PAYMENT.SALE.REFUNDED | Current completed native sale refund and original sale fully refunded | Revoke subscription entitlement linked to the sale. This is not a v2 capture refund. |
| CUSTOMER.DISPUTE.CREATED / UPDATED / RESOLVED | Current dispute has one transaction for the expected merchant | Suspend while disputed; confirmed seller-favour outcome restores eligible, nonterminal access. |

PayPal subscription payments still expose sale IDs. The refund verification reads the documented native sale/refund resources; it does not create deprecated v1 payments or pretend these IDs are v2 captures. Confirm these read permissions and lifecycle notifications on your own PayPal app before Live use.

The verification API call is online; a timeout is not a valid signature. The worker requests the narrow transaction time window around the native payment timestamp and requires an exact transaction ID match. No unsupported sandbox flag is invented inside a capture; fixed account origin/app credentials establish the configured mode. A new paid subscription without a prior local mapping still needs your verified initial-purchase policy.

## Server examples and deployment

- [Complete PHP service and hosting setup](/docs/v1/guides/store-service)
- [Complete Node.js ingress](/docs/v1/guides/store-service/node)

The PHP application contains the native verification, provider API reads, durable ledger and licensing worker. Optional Node/Python examples are complete raw-body ingress servers with an explicit PHP service dependency, not independent native payment workers. The PHP shared-hosting path needs neither Node nor a long-running process.

1. Download [all exact sources](/docs/downloads/store-integrations.zip) and check the [SHA-256 manifest](/docs/store-examples.json).
2. Follow the shared setup without changing the provider/module source or putting private config under a web root.
3. Set config.json provider/merchant/mode and offer_map; set fulfillment.json source to the identical provider string and map the approved internal offer to your [product plan](/docs/v1/start/glossary#product-plan).
4. Run the private work cron, inspect its aggregate states, and then run the separate mail cron. Keep both ledgers/backups persistent.
5. Configure [SMTP delivery](/docs/v1/guides/store-service/email) for your own sender. It reuses the encrypted saved key; it never creates another license to resend.

## Complete provider module

This exact module is loaded by the [shared normalizer](/docs/v1/guides/store-service/normalizer). It depends on the complete [native authentication and HTTP client](/docs/v1/guides/store-service/security) and [durable service source](/docs/v1/guides/store-service/worker). It is not a standalone copy/paste webhook endpoint.

```php
<?php
declare(strict_types=1);
// Complete provider module for StoreNormalizer; loaded by normalize.php.
// Requires the shared service/archive, not a standalone payment endpoint.
trait StorePaypalNormalizer
{
    private function paypal(array $event): array
    {
        $type = $event['event_type'] ?? ''; $resource = $event['resource'] ?? [];
        if ($type === 'PAYMENT.CAPTURE.COMPLETED') {
            $capture = $this->get('/v2/payments/captures/' . $this->ref($resource['id'] ?? null));
            storeRequire(($capture['status'] ?? '') === 'COMPLETED', 'CAPTURE_NOT_COMPLETED');
            $orderId = $capture['supplementary_data']['related_ids']['order_id'] ?? null;
            $order = $this->get('/v2/checkout/orders/' . $this->ref($orderId));
            storeRequire(($order['status'] ?? '') === 'COMPLETED', 'ORDER_NOT_COMPLETED');
            $unit = $this->one($order['purchase_units'] ?? []);
            storeRequire(($unit['payee']['merchant_id'] ?? null) === $this->config['seller_id'], 'WRONG_PAYEE');
            $item = $this->one($unit['items'] ?? []);
            $captures = $unit['payments']['captures'] ?? [];
            storeRequire(count($captures) === 1 && ($captures[0]['id'] ?? null) === ($capture['id'] ?? null) && ($captures[0]['status'] ?? '') === 'COMPLETED', 'CAPTURE_ORDER_MISMATCH');
            return [$this->paid((string) $orderId, (string) $capture['id'], (string) ($item['sku'] ?? ''),
                (string) ($order['payer']['email_address'] ?? ''), trim(($order['payer']['name']['given_name'] ?? '') . ' ' . ($order['payer']['name']['surname'] ?? '')), false, [(string) $capture['id']])];
        }
        if ($type === 'PAYMENT.SALE.COMPLETED') {
            $subId = $resource['billing_agreement_id'] ?? null;
            $subscription = $this->get('/v1/billing/subscriptions/' . $this->ref($subId));
            storeRequire(($subscription['status'] ?? '') === 'ACTIVE', 'SUBSCRIPTION_NOT_ACTIVE');
            $created = $resource['create_time'] ?? null;
            storeRequire(is_string($created) && strtotime($created) !== false, 'PAYMENT_TIME_REQUIRED');
            $start = gmdate('Y-m-d\TH:i:s\Z', strtotime($created) - 60);
            $end = gmdate('Y-m-d\TH:i:s\Z', strtotime($created) + 60);
            $transactions = $this->get('/v1/billing/subscriptions/' . $this->ref($subId) . '/transactions?start_time=' . rawurlencode($start) . '&end_time=' . rawurlencode($end));
            $matched = array_values(array_filter($transactions['transactions'] ?? [], static fn(array $item): bool => ($item['id'] ?? null) === ($resource['id'] ?? null) && ($item['status'] ?? '') === 'COMPLETED'));
            storeRequire(count($matched) === 1, 'SUBSCRIPTION_PAYMENT_NOT_CONFIRMED');
            $subscriber = $subscription['subscriber'] ?? [];
            return [$this->paid((string) $subId, (string) $resource['id'], (string) ($subscription['plan_id'] ?? ''),
                (string) ($subscriber['email_address'] ?? ''), trim(($subscriber['name']['given_name'] ?? '') . ' ' . ($subscriber['name']['surname'] ?? '')), true, [(string) $resource['id']])];
        }
        if ($type === 'PAYMENT.SALE.REFUNDED') {
            // Billing subscription transactions use sale IDs, not v2 capture IDs.
            // Read the native refund and original sale; never initiate a v1 payment.
            $refund = $this->get('/v1/payments/refund/' . $this->ref($resource['id'] ?? null));
            storeRequire(($refund['state'] ?? '') === 'completed', 'REFUND_NOT_COMPLETED');
            $saleId = $refund['sale_id'] ?? null;
            $sale = $this->get('/v1/payments/sale/' . $this->ref($saleId));
            storeRequire(($sale['id'] ?? null) === $saleId && ($sale['state'] ?? '') === 'refunded', 'PARTIAL_REFUND_REQUIRES_REVIEW');
            return [$this->negative((string) $saleId, (string) $event['id'], 'refunded')];
        }
        if (str_starts_with($type, 'BILLING.SUBSCRIPTION.')) {
            $subscription = $this->get('/v1/billing/subscriptions/' . $this->ref($resource['id'] ?? null));
            $state = match ($subscription['status'] ?? '') { 'SUSPENDED' => 'failed', 'CANCELLED' => 'cancelled', 'EXPIRED' => 'ended', default => null };
            return $state === null ? [] : [$this->negative((string) $subscription['id'], (string) $event['id'], $state)];
        }
        if ($type === 'PAYMENT.CAPTURE.REFUNDED') {
            $refund = $this->get('/v2/payments/refunds/' . $this->ref($resource['id'] ?? null));
            storeRequire(($refund['status'] ?? '') === 'COMPLETED', 'REFUND_NOT_COMPLETED');
            $captureId = $resource['supplementary_data']['related_ids']['capture_id'] ?? null;
            // Native refund events can omit capture_id; use the API's fixed-origin up link.
            if (!is_string($captureId)) {
                foreach ($refund['links'] ?? [] as $link) {
                    if (($link['rel'] ?? '') === 'up' && preg_match('~/v2/payments/captures/([A-Za-z0-9_-]+)$~D', (string) ($link['href'] ?? ''), $match)) { $captureId = $match[1]; }
                }
            }
            $capture = $this->get('/v2/payments/captures/' . $this->ref($captureId));
            storeRequire(($capture['status'] ?? '') === 'REFUNDED', 'PARTIAL_REFUND_REQUIRES_REVIEW');
            return [$this->negative((string) $captureId, (string) $event['id'], 'refunded')];
        }
        if (str_starts_with($type, 'CUSTOMER.DISPUTE.')) {
            $dispute = $this->get('/v1/customer/disputes/' . $this->ref($resource['dispute_id'] ?? null));
            $transaction = $this->one($dispute['disputed_transactions'] ?? []);
            storeRequire(($transaction['seller']['merchant_id'] ?? null) === $this->config['seller_id'], 'WRONG_DISPUTE_MERCHANT');
            $state = ($dispute['dispute_outcome']['outcome_code'] ?? '') === 'RESOLVED_SELLER_FAVOUR' ? 'recovered' : 'disputed';
            return [$this->negative((string) ($transaction['seller_transaction_id'] ?? ''), (string) $event['id'], $state)];
        }
        return [];
    }
}
```

## Test in Sandbox

1. Confirm invalid/missing proof cannot issue, and no key or API credential appears in the HTTP reply.
2. Complete one permitted Test purchase in your merchant account. Confirm the provider delivery, then run work and verify one DONE issue and a saved encrypted key.
3. Deliver to your verified test inbox and activate that key with the same buyer email and Sandbox App ID. Test a mismatched email too.
4. Redeliver the same event and overlapping supported paid events. Confirm no second key and no additional renewal days for the same paid receipt.
5. For supported subscriptions, test initial payment, another confirmed paid period, current failure/recovery and cancellation. Verify cancellation does not remove already-paid time.
6. Test a supported full refund and any exposed dispute lifecycle. Check revoked/suspended state in KDPika; never simulate a refund only by editing the browser return URL.
7. Test stale events and a missing initial mapping. Unknown, partial or unsupported cases must be held for review, not silently fulfilled.

Expected result: one authorized initial license, one additive extension per supported new paid receipt, no duplicate grant, no access from an unverified event, and a private delivery record. Renewal adds your mapped integer days, not an exact calendar-period-end update.

## Verification boundary

The exact source was executed through local HTTP ingress and explicit synthetic provider-schema API fixtures, with real isolated KDPika Sandbox issuance, [activation](/docs/v1/start/glossary#activation) and supported lifecycle writes. Fixture signatures use local test secrets, not a provider's private signing infrastructure. No real PayPal account purchase is claimed. Your account's permissions, actual delivery format, TLS, provider checkout and email inbox still need the procedure above before Live use. Native WordPress/merchant account execution was unavailable in this documentation environment; this is the explicit contract-test boundary, not recorded-payment evidence.

## Go-live checklist

- Review the unsupported boundaries above; do not sell a lifecycle this adapter does not implement.
- Complete your provider account verification and real Test delivery procedure; check merchant, recipient, item and mode.
- Use a separate Live config/state and scoped Live KDPika key; set KDP_CONFIRM_LIVE only after review. Never turn existing Sandbox ledgers into Live by editing their binding.
- Replace provider credentials/destination secret with the matching Live account values. Do not reuse example placeholders.
- Remove every loopback test override, use verified HTTPS and keep credentials and SQLite/encryption backups outside all web roots.
- Monitor private REVIEW/IN_FLIGHT and mail failures. A missing acknowledgement is not proof a write or email did not happen; reconcile before retrying.
- Retain business-effect identities to prevent reissuance. Apply your documented retention, refund, support and key-redelivery policies.

## Common mistakes

- Shipping a server management key inside an application or theme.
- Issuing from a redirect, subscription-created event or unverified notification.
- Granting both initial checkout and initial recurring invoice independently.
- Deleting the durable ledger or creating a new effect ID after a timeout.
- Treating a saved key or SMTP acceptance as confirmed inbox delivery.

## Official sources

Reviewed for this reference on 18 October 2026. Provider documentation can change independently; check your pinned API and account before upgrading.

- [Official reference 1](https://developer.paypal.com/api/webhooks/v1/)
- [Official reference 2](https://developer.paypal.com/api/subscriptions/v1/)
- [Official reference 3](https://developer.paypal.com/api/payments/v2/)
- [Official reference 4](https://developer.paypal.com/api/deprecated/payments/v1/refund-get)
- [Official reference 5](https://developer.paypal.com/api/deprecated/payments/v1/sale-get)


## Expected result

Only a confirmed provider purchase authorizes a licensing effect. A durable HTTP receipt, completed licensing operation and SMTP acceptance are separate outcomes. Inspect aggregate worker states and verify the resulting license with the same buyer email; no full key appears in public replies.


## Security notes

Use least-privilege private provider and product-scoped licensing credentials, verified HTTPS, owner-only persistent state and separate secrets per account/environment. Never trust browser payment claims. Preserve held operations after an unknown result and reconcile before another write; exactly-once behavior across systems is not guaranteed.


## Troubleshooting

For a receipt refusal check raw-byte preservation, the matching endpoint secret/account/mode, server clock where applicable and the private bootstrap layout. For REVIEW/IN_FLIGHT inspect provider state, safe private error codes and the licensing ledger before retrying. Missing permissions, recipient data or an initial mapping are not successful payments. For mail verify the separate SMTP ledger and provider delivery evidence. Do not post raw events, keys or SQLite files publicly.


## Next steps

Run the selected [provider Sandbox procedure](/docs/v1/guides/store-service#choose-your-provider), then test [private key email delivery](/docs/v1/guides/store-service/email). Review [supported recipe limitations](/docs/v1/guides/recipes) before enabling Live.


## Related

- [Generic store trust boundary](/docs/v1/guides/any-store)
- [API idempotency](/docs/v1/api/idempotency)
- [Private service setup](/docs/v1/guides/store-service)
- [Manual key delivery](/docs/v1/guides/manual-issuance)
